GitHub Actions
repo($path)->actions() drives GitHub Actions for one repository: start a workflow on a ref, get back the run GitHub started, follow it — its status, its jobs and each job’s steps — and cancel it. GitLab and Bitbucket throw FeatureNotSupportedException; their pipelines aren’t mapped.
use RoundlyConsulting\Git\Enums\WorkflowStatus;
use RoundlyConsulting\Git\Facades\Git;
$actions = Git::github($credential)->repo('acme/infra')->actions();
$dispatched = $actions->dispatch('deploy.yml', 'refs/heads/main', ['request_id' => $id]);
$run = $actions->run($dispatched->runId); // WorkflowRun
$run->isActive(); // poll until completed
$actions->runs('deploy.yml')->branch('main')->event('workflow_dispatch')
->status(WorkflowStatus::InProgress)->createdAfter($since)->get(); // Page<WorkflowRun>
$actions->jobs($run->id)->allAttempts()->lazy(); // WorkflowJob, with ->steps
$actions->jobs($run->id)->perPage(1)->get()->total; // did the run start any job?
$actions->cancel($run->id); // true = accepted, false = already done| Handle method | Returns |
|---|---|
dispatch($workflow, $ref, $inputs = []) | DispatchedWorkflow — the runId, apiUrl and url of the run GitHub started, plus dispatchedAt. |
runs(?$workflow) | WorkflowRunQuery — newest first; no workflow means every workflow of the repository. |
run($id) | WorkflowRun — the run as it is now. |
jobs($runId) | WorkflowJobQuery — the latest attempt unless allAttempts() or attempt($n). |
cancel($runId) | bool — true when GitHub accepted the cancel, false when the run had already finished (409). |
Every workflow and run id is checked before a request — an id lands in a URL, and ../7 or 7/jobs would address another resource. A workflow is its file name (deploy.yml — one segment ending .yml or .yaml) or its numeric id, and a run id is digits only; anything else throws OutOfScopeException. dispatch() and cancel() need a credential.
Dispatching a workflow
- $ref is sent as given — main and refs/heads/main both work. $inputs are the workflow’s declared inputs, each a string, number or boolean; a positional key or a nested or null value throws InvalidArgumentException. How many inputs a workflow may take is GitHub’s 422, not a client-side cap.
- On github.com and GHE.com, GitHub answers with the run it started, so runId, apiUrl and url are set. GitHub Enterprise Server hasn’t documented that answer, so there all three are null — find the run as shown below.
- dispatchedAt is the local clock, read before the request leaves, so a query from it cannot miss the run.
- A dispatch is never retried: a lost answer followed by a retry would start a second build.
Finding a run without runId
On GitHub Enterprise Server, or to adopt a run started elsewhere, put a marker in the workflow’s run-name — run-name: deploy [${{ inputs.request_id }}] — and look for it among the runs created since the dispatch:
use RoundlyConsulting\Git\Dto\WorkflowRun;
// .github/workflows/deploy.yml — run-name: deploy [${{ inputs.request_id }}]
$candidates = $actions->runs('deploy.yml')
->event('workflow_dispatch')
->branch('main')
->createdAfter($dispatched->dispatchedAt->copy()->subMinute())
->get();
$run = $candidates->collect()->first(fn (WorkflowRun $run) => str_contains($run->displayTitle, "[{$id}]"));The matching policy — which marker, the oldest or the newest run, extra checks on path or headBranch — stays with you; no extra API call is made for it.
Querying runs and jobs
- runs() filters with branch(), event(), status(), actor() (a login), headSha(), createdAfter() and createdBefore(). status() takes a WorkflowStatus or a WorkflowConclusion — GitHub accepts both in one filter — and throws InvalidArgumentException for Unknown.
- createdAfter() and createdBefore() compare in UTC to the second and never move your Carbon instance. excludePullRequests() only leaves each run’s pull_requests out of GitHub’s answer — it filters nothing.
- The page’s total is GitHub’s total_count. GitHub stops a filtered run search at 1,000 results, so lazy() ends there too — a total above what you read says so.
- jobs() reads the run’s latest attempt; allAttempts() reads every attempt and attempt($n) one of them (1 is the first run). Asking for both, or for an attempt below 1, throws InvalidArgumentException.
Polling and cancelling
- run($id) goes through the conditional cache, so with GIT_CACHE_ENABLED=true an unchanged run comes back as a 304, which GitHub doesn’t count against the primary rate limit.
- isActive() is true for anything but completed — an Unknown status included — so a poll never stops on a state it cannot read. A status or conclusion GitHub adds later reads as Unknown, which never counts as completed or successful.
- runStartedAt is the creation time while a run is queued or waiting; trust it only once the run is in progress.
- GitHub cancels asynchronously: true means the cancel was accepted, and the outcome is the next run() (wasCancelled()). false means the run had already finished. Force-cancel is not offered.
Result objects
| Object | Fields |
|---|---|
WorkflowRun | id, workflowId, name, displayTitle, status, conclusion, event, path, headBranch, headSha, headRepository, runNumber, runAttempt, actor, triggeringActor, url, createdAt, updatedAt, runStartedAt — plus isCompleted(), isActive(), succeeded(), wasCancelled() |
WorkflowJob | id, runId, name, status, conclusion, headSha, runAttempt, workflowName, headBranch, startedAt, completedAt, runnerName, labels, url, steps (list of JobStep) — plus succeeded() and step($name) |
JobStep | number, name, status, conclusion, startedAt, completedAt — plus succeeded() |
DispatchedWorkflow | provider, workflow, ref, runId, apiUrl, url (null when GitHub didn’t say), dispatchedAt |
WorkflowStatus | Requested, Queued, Pending, Waiting, InProgress, Completed, Unknown |
WorkflowConclusion | Success, Failure, Neutral, Cancelled, Skipped, TimedOut, ActionRequired, Stale, StartupFailure, Unknown |
A run or job payload without a usable id throws InvalidArgumentException naming the field — an id is never made up.
Permissions
A scoped GitHub App token needs these permissions per call:
| Call | Permission |
|---|---|
dispatch(), cancel() | actions: write |
runs(), run(), jobs() | actions: read |
compare(), branch(), activity() | contents: read |
use RoundlyConsulting\Git\Dto\Credentials\GithubAppToken;
use RoundlyConsulting\Git\Dto\Input\InstallationTokenScope;
$credential = GithubAppToken::for($appId, $installationId, $privateKey)->forScope(
new InstallationTokenScope(repositories: ['acme/infra'], permissions: ['actions' => 'write']),
);
Git::github($credential)->repo('acme/infra')->actions()->dispatch('deploy.yml', 'main');Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.