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@v0.1.0Entrypoint
Return Type
Github Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 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
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| repo | String ! | - |
|
| sha | String ! | - | Anything the commits API resolves: a commit sha, a branch,
or a TAG NAME. Pass |
| checkName | String ! | - | The check-run name that must be green, e.g. |
| ignoredRe | String ! | - | POSIX/JS regex of paths allowed to change without CI, e.g.
|
| cacheBust | String ! | - | MUST vary per run (the run id). See the module header. |
| maxCommits | Integer ! | 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@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
verify-tag-ci --token env:MYSECRET --repo string --sha string --check-name string --ignored-re string --cache-bust string --max-commits integerfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| repo | String ! | - | No description provided |
| runId | String ! | - | The workflow run id ( |
| jobNames | String ! | - | JSON |
| cacheBust | String ! | - | MUST vary per run. See the module header. |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
lost-runner-verdict --token env:MYSECRET --repo string --run-id string --job-names string --cache-bust stringfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| repo | String ! | - | No description provided |
| runId | String ! | - | No description provided |
| cacheBust | String ! | - | 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@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
rerun-failed-jobs --token env:MYSECRET --repo string --run-id string --cache-bust stringfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| repo | String ! | - | No description provided |
| tag | String ! | - | No description provided |
| cacheBust | String ! | - | MUST vary per run. See the module header. |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
release-exists --token env:MYSECRET --repo string --tag string --cache-bust stringfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| repo | String ! | - | No description provided |
| tag | String ! | - | No description provided |
| cacheBust | String ! | - | MUST vary per run. A POST is cached like a GET. |
| notes | String ! | "" | Release body. Empty asks GitHub to generate the notes,
which is what |
| changelog | File | - | Optional CHANGELOG.md; when |
| updateExisting | Boolean ! | false | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
create-release --token env:MYSECRET --repo string --tag string --cache-bust string --notes string --update-existing booleanfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| tag | String ! | - | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
is-prerelease --tag stringfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| changelog | File ! | - | No description provided |
| tag | String ! | - | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
changelog-notes --changelog file:path --tag stringfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| level | String ! | - | warning | error | notice | debug |
| message | String ! | - | No description provided |
| title | String ! | "" | Optional annotation title. |
| file | String ! | "" | Optional path to attach the annotation to. |
| line | Integer ! | 0 | Optional line; 0 means absent. |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
annotation --level string --message string --title string --file string --line integerfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| heading | String ! | - | No description provided |
| bullets | String ! | "" | JSON |
| body | String ! | "" | Free markdown appended after the bullets. |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
summary-section --heading string --bullets string --body stringfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| markdown | String ! | - | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
summary-file --markdown stringfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| runId | String ! | - | No description provided |
| stuckStep | String ! | - | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
lost-runner-warning --run-id string --stuck-step stringfunc (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
| Name | Type | Default Value | Description |
|---|---|---|---|
| runId | String ! | - | No description provided |
| runUrl | String ! | - | No description provided |
| stuckStep | String ! | - | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
lost-runner-summary --run-id string --run-url string --stuck-step stringfunc (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)
}