Commit

GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs

Workflows live in .g1t/workflows in GitHub's own format, and g1t never reads .github: moving a repository is renaming .github to .g1t. Local actions still named ./.github/... are found under .g1t. The site gains an Actions page (workflows, runs, manual runs with their inputs, turning a workflow off, notes on what runs differently), a run page (jobs, steps, live logs with groups, errors and annotations, cancel and re-run), Secrets and variables for repositories and workspaces, and workflow statuses on pull requests. Fixes: the runner answers start requests with a value, and a sandbox that fails to start fails its job with why; log sequence numbers go to D1 as numbers, not BigInts. The guide is docs.g1t.sh/guides/actions.

syntaqxcommitted Parent9490499Browse files
36 files+1540−330/36 viewed
+1−1
580580 }
581581 Op::UpdateAutomation => "Turn an automation on or off without changing its file. Members only.",
582582 Op::ListWorkflows => {
583− "A repository's GitHub Actions workflows, read from .github/workflows on its default branch: the events that start each, whether it is on, any problem with its file, notes on anything that runs differently on g1t, its manual-run inputs, and its last run."
583+ "A repository's GitHub Actions workflows, read from .g1t/workflows (GitHub's format, so a repository moves by renaming .github to .g1t) on its default branch: the events that start each, whether it is on, any problem with its file, notes on anything that runs differently on g1t, its manual-run inputs, and its last run."
584584 }
585585 Op::ListWorkflowRuns => {
586586 "A repository's workflow runs, newest first: of one workflow (its id or file name), a branch, an event, a pull request's number, or a commit."
+1−0
7777 { label: 'Integrations', slug: 'guides/integrations' },
7878 { label: 'Model providers', slug: 'guides/models' },
7979 { label: 'Webhooks', slug: 'guides/webhooks' },
80+ { label: 'GitHub Actions', slug: 'guides/actions' },
8081 { label: 'Automations', slug: 'guides/automations' },
8182 ],
8283 },
+146−0
1+---
2+title: GitHub Actions
3+description: Your GitHub Actions workflows run on g1t as they are. Rename .github to .g1t and push.
4+---
5+
6+g1t runs GitHub Actions workflows. They are written exactly as on GitHub,
7+and kept in `.g1t/workflows/` instead of `.github/workflows/`.
8+
9+## Moving from GitHub
10+
11+```sh
12+git mv .github .g1t
13+git commit -m "Run our workflows on g1t"
14+git push g1t main
15+```
16+
17+That is the whole move. Everything in the folder comes along: workflows,
18+local actions under `.g1t/actions/` and anything else you keep there.
19+Workflows that still say `uses: ./.github/actions/setup` find it under
20+`.g1t/` once `.github` is gone.
21+
22+g1t never reads `.github`. A repository mirrored to both places can keep
23+`.github` for GitHub and `.g1t` for g1t, side by side.
24+
25+Then add your [secrets and variables](#secrets-and-variables): GitHub never
26+gives their values out, so they cannot be copied across.
27+
28+## What runs
29+
30+| On GitHub | On g1t |
31+| --- | --- |
32+| `on:` `push` (branches, tags, paths), `pull_request`, `pull_request_target`, `issues`, `issue_comment`, `pull_request_review`, `schedule`, `workflow_dispatch` | The same, from g1t's own pushes, pull requests, issues and comments. |
33+| `jobs`, `needs`, `if`, `outputs`, `env`, `defaults`, `timeout-minutes`, `continue-on-error` | The same. |
34+| `strategy.matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`, a matrix from `fromJSON(needs.…)` | The same. |
35+| `concurrency` with `cancel-in-progress` | The same. |
36+| `${{ }}` expressions: every operator, function and context | The same, including `hashFiles`, `success()`, `failure()`, `always()` and `cancelled()`. |
37+| `run:` with `bash`, `sh`, `python` or a custom shell | The same. |
38+| JavaScript actions (`uses: owner/repo@v4`) | Fetched from GitHub and run as they are, with Node 22. |
39+| Composite actions | The same. |
40+| `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. |
41+| `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. |
42+| `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run. |
43+| `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same; `GITHUB_TOKEN` is a token for g1t. |
44+
45+The **Actions** page of a workflow says, under *How this runs on g1t*,
46+anything in it that runs differently.
47+
48+### Not yet
49+
50+- **Windows and macOS runners.** Jobs run on Linux; a job with
51+ `runs-on: windows-latest` or `macos-latest` fails, and says so.
52+- **Docker** container actions, `services:` containers and `container:`.
53+- **Reusable workflows** (`uses:` on a job).
54+- **Artifacts and the cache.** `actions/upload-artifact` does nothing and
55+ says so; `download-artifact` fails. `actions/cache` always misses.
56+- **Environments' protection rules**. A job with `environment:` runs with
57+ the repository's secrets.
58+
59+## The runner
60+
61+Jobs run in a fresh sandbox each: Debian with Node 22, Python 3, Go, Rust,
62+`build-essential`, `git`, `curl`, `jq` and passwordless `sudo`, in GitHub's
63+layout (`/home/runner/work`, `RUNNER_TEMP`, `RUNNER_TOOL_CACHE`).
64+`runner.os` is `Linux`. `ubuntu-latest`, `ubuntu-24.04`, `self-hosted` and
65+other Linux labels all run here. Setup actions such as
66+`actions/setup-node` and `actions/setup-python` install other versions as
67+they do on GitHub.
68+
69+A job runs for at most 60 minutes, whatever its `timeout-minutes`.
70+
71+## Runs and logs
72+
73+Open a repository's **Actions** page, in its sidebar. Pick a workflow to
74+see its runs, run it by hand if it has `workflow_dispatch`, or turn it off
75+without touching its file.
76+
77+A run's page shows its jobs, each job's steps, and their logs as they are
78+written. Groups fold, errors and warnings are marked, and secrets are
79+replaced with `***`. **Cancel**, **Re-run all jobs** and **Re-run failed
80+jobs** do what they say.
81+
82+## Pull requests
83+
84+A run on a pull request's latest commit is a check on it:
85+
86+- While a workflow runs, the pull request waits for it before merging.
87+- When one fails, merging is refused, as for failed acceptance checks.
88+ Where the repository allows ignoring checks, a member can merge anyway.
89+- A pull request a **g1t agent** is working on goes back to the agent
90+ when a workflow fails. The agent reads the run and its logs with the
91+ same tools you have, fixes the cause, and pushes; the workflows run
92+ again.
93+
94+## Secrets and variables
95+
96+Secrets are read as `${{ secrets.NAME }}` and variables as
97+`${{ vars.NAME }}`. Set them under **Settings → Secrets and variables**:
98+
99+- a repository's, which its members manage;
100+- a workspace's, which owners manage and every repository reads. A
101+ repository's own of the same name wins.
102+
103+Secret values are sealed when saved and never shown again. Pull requests
104+from people outside the workspace run without secrets, and with a
105+`GITHUB_TOKEN` that cannot write.
106+
107+## Who may run workflows
108+
109+While g1t is in preview, workflows run in the workspaces g1t has opened
110+them to. Elsewhere a run is recorded with its jobs failed and the reason.
111+
112+## From the API
113+
114+The routes are GitHub's, so scripts written for GitHub's API mostly work
115+with `https://api.g1t.sh` in place of `https://api.github.com`.
116+
117+| Tool | Route |
118+| --- | --- |
119+| `list_workflows` | `GET /repos/{owner}/{repo}/actions/workflows` |
120+| `list_workflow_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha` |
121+| `get_workflow_run` | `GET /repos/{owner}/{repo}/actions/runs/{id}` |
122+| `get_job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=` |
123+| `dispatch_workflow` | `POST /repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches` with `ref` and `inputs` |
124+| `cancel_workflow_run` | `POST /repos/{owner}/{repo}/actions/runs/{id}/cancel` |
125+| `rerun_workflow_run` | `POST …/runs/{id}/rerun`, or `…/rerun-failed-jobs` |
126+| `update_workflow` | `PUT …/workflows/{workflow}/enable` and `…/disable` |
127+| `list_actions_secrets`, `set_actions_secret`, `delete_actions_secret` | `GET`, `PUT` and `DELETE /repos/{owner}/{repo}/actions/secrets/{name}` |
128+| `list_actions_variables`, `set_actions_variable`, `delete_actions_variable` | `GET` and `POST /repos/{owner}/{repo}/actions/variables`, `PATCH` and `DELETE …/variables/{name}` |
129+
130+Workspace secrets and variables are under
131+`/workspaces/{workspace}/actions/secrets` and `…/variables`. Unlike
132+GitHub's, a secret is sent as plain `value` over HTTPS, not encrypted to a
133+public key.
134+
135+```sh
136+curl -X POST https://api.g1t.sh/repos/acme/web/actions/workflows/ci.yml/dispatches \
137+ -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
138+ -d '{"ref": "main", "inputs": {"environment": "staging"}}'
139+```
140+
141+## Automations
142+
143+Workflows run code. For rules that act on g1t itself, such as labelling an
144+issue, putting an agent on it, or posting to chat, without a runner, see
145+[Automations](/guides/automations/), which live beside workflows in
146+`.g1t/automations/`.
+1−0
4848 <li><a href="/guides/integrations/">Integrations: Sentry, Jira, Linear</a></li>
4949 <li><a href="/guides/models/">Model providers: Anthropic, OpenAI, Gemini</a></li>
5050 <li><a href="/guides/webhooks/">Webhooks</a></li>
51+ <li><a href="/guides/actions/">GitHub Actions: rename .github to .g1t</a></li>
5152 <li><a href="/guides/automations/">Automations</a></li>
5253 </ul>
5354 </div>
+21−0
132132
133133 Each has a workspace route too, under `/workspaces/{workspace}/hooks`.
134134
135+## GitHub Actions
136+
137+See [GitHub Actions](/guides/actions/). Workflows are GitHub's, kept in `.g1t/workflows/`. Routes are GitHub's own.
138+
139+| Tool | Required | What it does | Route |
140+| --- | --- | --- | --- |
141+| `list_workflows` | `repo` | The workflows, with their events, state, problems, notes on what runs differently, manual-run inputs and last run. | `GET /repos/{owner}/{name}/actions/workflows` |
142+| `list_workflow_runs` | `repo` | Runs, newest first; filter by `workflow`, `branch`, `event`, `pull` or `sha`. | `GET /repos/{owner}/{name}/actions/runs` |
143+| `get_workflow_run` | `repo`, `id` | A run with its jobs, their steps and annotations. | `GET /repos/{owner}/{name}/actions/runs/{id}` |
144+| `get_job_logs` | `repo`, `job` | A job's log after `after`; `done` says if more will come. | `GET /repos/{owner}/{name}/actions/jobs/{job}/logs` |
145+| `dispatch_workflow` | `repo`, `workflow` | Run a `workflow_dispatch` workflow on `ref` with `inputs`. Members only. | `POST /repos/{owner}/{name}/actions/workflows/{workflow}/dispatches` |
146+| `cancel_workflow_run` | `repo`, `id` | Cancel a run. Members only. | `POST /repos/{owner}/{name}/actions/runs/{id}/cancel` |
147+| `rerun_workflow_run` | `repo`, `id` | Run it again; `failed_only` for the jobs that did not succeed. Members only. | `POST /repos/{owner}/{name}/actions/runs/{id}/rerun` |
148+| `update_workflow` | `repo`, `workflow`, `enabled` | Turn a workflow on or off. Members only. | `PATCH /repos/{owner}/{name}/actions/workflows/{workflow}` |
149+| `list_actions_secrets` | `repo` or `workspace` | Secret names, never values. | `GET /repos/{owner}/{name}/actions/secrets` |
150+| `set_actions_secret` | `setting`, `value` | Add or replace a secret. | `PUT /repos/{owner}/{name}/actions/secrets/{name}` |
151+| `delete_actions_secret` | `setting` | Remove a secret. | `DELETE /repos/{owner}/{name}/actions/secrets/{name}` |
152+| `list_actions_variables` | `repo` or `workspace` | Variables with their values. | `GET /repos/{owner}/{name}/actions/variables` |
153+| `set_actions_variable` | `setting`, `value` | Add or replace a variable. | `POST /repos/{owner}/{name}/actions/variables` |
154+| `delete_actions_variable` | `setting` | Remove a variable. | `DELETE /repos/{owner}/{name}/actions/variables/{name}` |
155+
135156 ## Automations
136157
137158 See [Automations](/guides/automations/). An automation is a file in `.g1t/automations/` on the default branch: to add or change one, commit it.
+272−0
1+/**
2+ * Pieces of the Actions pages: how a run, job or step stands, how long it
3+ * took, and a job's log as GitHub shows one, with its groups folded and
4+ * its errors and warnings marked.
5+ */
6+import { AlertTriangle, Ban, Check, ChevronRight, CircleDashed, CircleSlash, Clock, Info, Loader2, X } from "lucide-react";
7+import { useEffect, useMemo, useRef, useState } from "react";
8+import { Link } from "react-router";
9+
10+import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
11+
12+type Standing = { status: string; conclusion: Conclusion | null };
13+
14+/** One icon for where a run, job or step stands. */
15+export function StatusIcon({ status, conclusion, size = 16 }: Standing & { size?: number }) {
16+ if (status === "in_progress") return <Loader2 size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />;
17+ if (status !== "completed")
18+ return <Clock size={size} className="shrink-0 text-faint" aria-label={status === "pending" ? "Waiting its turn" : "Queued"} />;
19+ switch (conclusion) {
20+ case "success":
21+ return (
22+ <span className="inline-flex shrink-0 rounded-full bg-accent/15 p-0.5 text-accent" aria-label="Succeeded">
23+ <Check size={size - 4} strokeWidth={3} />
24+ </span>
25+ );
26+ case "failure":
27+ return (
28+ <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger" aria-label="Failed">
29+ <X size={size - 4} strokeWidth={3} />
30+ </span>
31+ );
32+ case "cancelled":
33+ return <Ban size={size} className="shrink-0 text-faint" aria-label="Cancelled" />;
34+ default:
35+ return <CircleDashed size={size} className="shrink-0 text-faint" aria-label="Skipped" />;
36+ }
37+}
38+
39+export function standingWord({ status, conclusion }: Standing): string {
40+ if (status === "in_progress") return "Running";
41+ if (status === "pending") return "Waiting for its concurrency group";
42+ if (status === "waiting") return "Waiting for the jobs it needs";
43+ if (status !== "completed") return "Queued";
44+ return { success: "Succeeded", failure: "Failed", cancelled: "Cancelled", skipped: "Skipped" }[conclusion ?? "skipped"];
45+}
46+
47+/** `1m 12s`, from two times, or from a start until now. */
48+export function duration(start: string | null, end: string | null): string {
49+ if (!start) return "";
50+ const ms = (end ? new Date(end).getTime() : Date.now()) - new Date(start).getTime();
51+ const seconds = Math.max(0, Math.round(ms / 1000));
52+ if (seconds < 60) return `${seconds}s`;
53+ const minutes = Math.floor(seconds / 60);
54+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
55+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
56+}
57+
58+/** `main` from `refs/heads/main`, `v1.2` from a tag, `#12` for a pull request. */
59+export function shortRef(ref: string): string {
60+ const pull = /^refs\/pull\/(\d+)\//.exec(ref);
61+ if (pull) return `#${pull[1]}`;
62+ return ref.replace(/^refs\/(heads|tags)\//, "");
63+}
64+
65+type Line = { kind: "text" | "error" | "warning" | "notice" | "debug" | "command"; text: string };
66+type Block = { kind: "line"; line: Line; number: number } | { kind: "group"; title: string; lines: { line: Line; number: number }[] };
67+
68+function classify(raw: string): Line | "group-end" | { group: string } {
69+ if (raw.startsWith("##[group]")) return { group: raw.slice(9) };
70+ if (raw.startsWith("##[endgroup]")) return "group-end";
71+ for (const kind of ["error", "warning", "notice", "debug"] as const) {
72+ if (raw.startsWith(`##[${kind}]`)) return { kind, text: raw.slice(kind.length + 4) };
73+ }
74+ if (raw.startsWith("[command]")) return { kind: "command", text: raw.slice(9) };
75+ return { kind: "text", text: raw };
76+}
77+
78+/** Lines into blocks: plain lines, and groups that fold. */
79+function blocks(text: string): Block[] {
80+ const out: Block[] = [];
81+ let group: Extract<Block, { kind: "group" }> | null = null;
82+ let number = 0;
83+ for (const raw of text.split("\n")) {
84+ if (raw === "" && number === 0) continue;
85+ const line = classify(raw);
86+ if (line === "group-end") {
87+ group = null;
88+ continue;
89+ }
90+ if ("group" in line) {
91+ group = { kind: "group", title: line.group, lines: [] };
92+ out.push(group);
93+ continue;
94+ }
95+ number += 1;
96+ if (group) group.lines.push({ line, number });
97+ else out.push({ kind: "line", line, number });
98+ }
99+ return out;
100+}
101+
102+const LINE_STYLE: Record<Line["kind"], string> = {
103+ text: "text-fg/85",
104+ error: "text-danger",
105+ warning: "text-warn",
106+ notice: "text-accent",
107+ debug: "text-faint",
108+ command: "text-muted",
109+};
110+
111+function LogLine({ line, number }: { line: Line; number: number }) {
112+ return (
113+ <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}>
114+ <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
115+ <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}>
116+ {line.kind === "error" && <span className="font-semibold">Error: </span>}
117+ {line.kind === "warning" && <span className="font-semibold">Warning: </span>}
118+ {line.text.replace(/^(Error|Warning|Notice): /, "")}
119+ </span>
120+ </div>
121+ );
122+}
123+
124+export function LogText({ text }: { text: string }) {
125+ const parsed = useMemo(() => blocks(text), [text]);
126+ if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
127+ return (
128+ <div className="py-1 font-mono text-xs leading-5">
129+ {parsed.map((block, index) =>
130+ block.kind === "line" ? (
131+ <LogLine key={index} line={block.line} number={block.number} />
132+ ) : (
133+ <details key={index} className="group/log">
134+ <summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 text-fg/85 hover:bg-raised/40">
135+ <span className="w-8 shrink-0" />
136+ <ChevronRight size={12} className="shrink-0 text-faint transition-transform group-open/log:rotate-90" />
137+ {block.title}
138+ </summary>
139+ {block.lines.map(({ line, number }) => (
140+ <LogLine key={number} line={line} number={number} />
141+ ))}
142+ </details>
143+ ),
144+ )}
145+ </div>
146+ );
147+}
148+
149+/**
150+ * A job's log, fetched as it grows, split by step: `render` gets each
151+ * step's text (0 is the job's setup).
152+ */
153+export function useJobLog(url: string, live: boolean): Map<number, string> {
154+ const [chunks, setChunks] = useState<LogChunk[]>([]);
155+ const after = useRef(0);
156+ useEffect(() => {
157+ after.current = 0;
158+ setChunks([]);
159+ }, [url]);
160+ useEffect(() => {
161+ let stopped = false;
162+ let timer: ReturnType<typeof setTimeout> | undefined;
163+ const load = async () => {
164+ try {
165+ // Pages of up to 500 chunks, until there is no more for now.
166+ for (;;) {
167+ const response = await fetch(`${url}?after=${after.current}`);
168+ if (!response.ok) break;
169+ const page = (await response.json()) as { chunks: LogChunk[]; done: boolean };
170+ if (stopped) return;
171+ if (page.chunks.length > 0) {
172+ after.current = page.chunks[page.chunks.length - 1].seq;
173+ setChunks((known) => [...known, ...page.chunks]);
174+ }
175+ if (page.chunks.length < 500) {
176+ if (!page.done && live) timer = setTimeout(load, 2000);
177+ break;
178+ }
179+ }
180+ } catch {
181+ if (!stopped && live) timer = setTimeout(load, 4000);
182+ }
183+ };
184+ load();
185+ return () => {
186+ stopped = true;
187+ if (timer) clearTimeout(timer);
188+ };
189+ }, [url, live]);
190+ return useMemo(() => {
191+ const byStep = new Map<number, string>();
192+ for (const chunk of chunks) byStep.set(chunk.step, (byStep.get(chunk.step) ?? "") + chunk.text);
193+ return byStep;
194+ }, [chunks]);
195+}
196+
197+const NOTE_ICON = {
198+ info: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
199+ warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
200+ unsupported: <CircleSlash size={14} className="mt-0.5 shrink-0 text-danger" />,
201+};
202+
203+/** What in a workflow runs differently on g1t, worst first. */
204+export function Notes({ notes }: { notes: WorkflowNote[] }) {
205+ if (notes.length === 0) return null;
206+ const order = { unsupported: 0, warning: 1, info: 2 };
207+ const sorted = [...notes].sort((a, b) => order[a.severity] - order[b.severity]);
208+ const blocking = notes.filter((n) => n.severity === "unsupported").length;
209+ return (
210+ <details className="group rounded-xl border border-line bg-surface" open={blocking > 0}>
211+ <summary className="cursor-pointer list-none px-4 py-2.5 text-sm">
212+ <span className="font-medium">How this runs on g1t</span>
213+ <span className="text-muted">
214+ {" · "}
215+ {blocking > 0 ? `${blocking} thing${blocking === 1 ? "" : "s"} g1t cannot run yet` : `${notes.length} note${notes.length === 1 ? "" : "s"}`}
216+ </span>
217+ </summary>
218+ <ul className="space-y-2 border-t border-line px-4 py-3 text-sm">
219+ {sorted.map((note, index) => (
220+ <li key={index} className="flex gap-2">
221+ {NOTE_ICON[note.severity]}
222+ <span>
223+ {note.job && <span className="font-mono text-xs text-muted">{note.job}: </span>}
224+ {note.message}
225+ </span>
226+ </li>
227+ ))}
228+ </ul>
229+ </details>
230+ );
231+}
232+
233+
234+/** What workflow runs said about a pull request's head, each linking to its run. */
235+export function WorkflowStatuses({ statuses }: { statuses: CommitStatus[] }) {
236+ if (statuses.length === 0) return null;
237+ const standing = (state: CommitStatus["state"]) =>
238+ state === "pending"
239+ ? { status: "in_progress", conclusion: null }
240+ : { status: "completed", conclusion: (state === "success" ? "success" : "failure") as Conclusion };
241+ const failed = statuses.filter((s) => s.state === "failure" || s.state === "error").length;
242+ const pending = statuses.filter((s) => s.state === "pending").length;
243+ return (
244+ <section className="rounded-xl border border-line bg-surface p-4">
245+ <h3 className="text-sm font-medium">
246+ {failed > 0 ? `${failed} workflow${failed === 1 ? "" : "s"} failed` : pending > 0 ? "Workflows running" : "Workflows passed"}
247+ </h3>
248+ <ul className="mt-3 space-y-2 text-sm">
249+ {statuses.map((status) => {
250+ const path = status.targetUrl?.replace(/^https:\/\/g1t\.sh/, "") ?? null;
251+ const row = (
252+ <>
253+ <StatusIcon {...standing(status.state)} size={14} />
254+ <span className="min-w-0 truncate">{status.context}</span>
255+ </>
256+ );
257+ return (
258+ <li key={status.context}>
259+ {path ? (
260+ <Link to={path} className="flex items-center gap-2 hover:text-fg" title={status.description ?? undefined}>
261+ {row}
262+ </Link>
263+ ) : (
264+ <span className="flex items-center gap-2">{row}</span>
265+ )}
266+ </li>
267+ );
268+ })}
269+ </ul>
270+ </section>
271+ );
272+}
+4−1
1−import { Settings, Webhook } from "lucide-react";
1+import { KeyRound, Settings, Webhook } from "lucide-react";
22
33 import { TabLink } from "./ui";
44
1212 <TabLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
1313 Webhooks
1414 </TabLink>
15+ <TabLink to={`${base}/settings/secrets`} icon={<KeyRound size={15} />}>
16+ Secrets and variables
17+ </TabLink>
1518 </nav>
1619 );
1720 }
+146−0
1+/**
2+ * A repository's or a workspace's secrets and variables, as workflows read
3+ * them: `secrets.NAME` and `vars.NAME`. A repository's list includes what
4+ * it inherits from its workspace, which its own of the same name replace.
5+ */
6+import { KeyRound, Trash2, Variable } from "lucide-react";
7+import { useEffect, useRef } from "react";
8+import { Form, useNavigation } from "react-router";
9+
10+import type { Setting, SettingKind } from "@g1t/contracts";
11+
12+import type { SecretsAction, SecretsData } from "../lib/secrets.server";
13+import { Button, EmptyState, ErrorText, Field, Input, Textarea, TimeAgo } from "./ui";
14+
15+function SettingRow({ setting, kind, manage, inherited }: { setting: Setting; kind: SettingKind; manage: boolean; inherited: boolean }) {
16+ const busy = useNavigation().state === "submitting";
17+ return (
18+ <li className="flex items-center gap-3 border-t border-line px-4 py-2.5 first:border-t-0">
19+ <span className="font-mono text-[0.8125rem]">{setting.name}</span>
20+ {kind === "variable" && setting.value != null && (
21+ <span className="min-w-0 truncate font-mono text-xs text-muted">{setting.value}</span>
22+ )}
23+ {inherited && (
24+ <span className="shrink-0 rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">From the workspace</span>
25+ )}
26+ <span className="ml-auto shrink-0 text-xs text-faint">
27+ Updated <TimeAgo at={setting.updatedAt} />
28+ </span>
29+ {manage && !inherited && (
30+ <Form method="post" className="shrink-0">
31+ <input type="hidden" name="intent" value="delete" />
32+ <input type="hidden" name="kind" value={kind} />
33+ <input type="hidden" name="name" value={setting.name} />
34+ <button
35+ type="submit"
36+ disabled={busy}
37+ aria-label={`Remove ${setting.name}`}
38+ title="Remove"
39+ className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
40+ >
41+ <Trash2 size={14} />
42+ </button>
43+ </Form>
44+ )}
45+ </li>
46+ );
47+}
48+
49+function Section({
50+ kind,
51+ items,
52+ scope,
53+ manage,
54+ action,
55+}: {
56+ kind: SettingKind;
57+ items: Setting[];
58+ scope: "repository" | "workspace";
59+ manage: boolean;
60+ action: SecretsAction | undefined;
61+}) {
62+ const navigation = useNavigation();
63+ const form = useRef<HTMLFormElement>(null);
64+ const mine = action?.kind === kind;
65+ // Clear the form once something was saved.
66+ useEffect(() => {
67+ if (mine && action?.done && navigation.state === "idle") form.current?.reset();
68+ }, [mine, action, navigation.state]);
69+ const secret = kind === "secret";
70+ return (
71+ <section>
72+ <h3 className="flex items-center gap-2 text-sm font-medium">
73+ {secret ? <KeyRound size={15} className="text-accent" /> : <Variable size={15} className="text-accent" />}
74+ {secret ? "Secrets" : "Variables"}
75+ </h3>
76+ <p className="mt-1 max-w-2xl text-sm text-muted">
77+ {secret ? (
78+ <>
79+ Read in workflows as <code className="text-fg">{"${{ secrets.NAME }}"}</code>. Values are sealed when
80+ saved, never shown again, and hidden in logs.
81+ </>
82+ ) : (
83+ <>
84+ Read in workflows as <code className="text-fg">{"${{ vars.NAME }}"}</code>. For settings that are not
85+ secret; their values are shown.
86+ </>
87+ )}
88+ {scope === "workspace" && " Every repository in the workspace reads these, unless it has its own of the same name."}
89+ </p>
90+ <div className="mt-4">
91+ {items.length === 0 ? (
92+ <EmptyState title={secret ? "No secrets yet" : "No variables yet"} />
93+ ) : (
94+ <ul className="overflow-hidden rounded-xl border border-line bg-surface">
95+ {items.map((item) => (
96+ <SettingRow key={`${item.scope}:${item.name}`} setting={item} kind={kind} manage={manage} inherited={item.scope !== scope} />
97+ ))}
98+ </ul>
99+ )}
100+ </div>
101+ {manage && (
102+ <Form ref={form} method="post" className="mt-4 grid gap-3 rounded-xl border border-line bg-surface p-4">
103+ <input type="hidden" name="intent" value="set" />
104+ <input type="hidden" name="kind" value={kind} />
105+ <Field label="Name" hint="Letters, digits and underscores. Saving one that exists replaces it.">
106+ <Input name="name" required placeholder={secret ? "NPM_TOKEN" : "DEPLOY_REGION"} autoComplete="off" />
107+ </Field>
108+ <Field label="Value">
109+ {secret ? (
110+ <Textarea name="value" required rows={3} autoComplete="off" spellCheck={false} />
111+ ) : (
112+ <Input name="value" autoComplete="off" />
113+ )}
114+ </Field>
115+ <div className="flex items-center gap-3">
116+ <Button type="submit" disabled={navigation.state === "submitting"}>
117+ {secret ? "Save secret" : "Save variable"}
118+ </Button>
119+ {mine && action?.done && <span className="text-sm text-muted">{action.done}</span>}
120+ </div>
121+ {mine && <ErrorText>{action?.error}</ErrorText>}
122+ </Form>
123+ )}
124+ </section>
125+ );
126+}
127+
128+export function SecretsPanel({
129+ data,
130+ action,
131+ scope,
132+ manage,
133+}: {
134+ data: SecretsData;
135+ action: SecretsAction | undefined;
136+ scope: "repository" | "workspace";
137+ manage: boolean;
138+}) {
139+ return (
140+ <div className="max-w-4xl space-y-12">
141+ <ErrorText>{data.error}</ErrorText>
142+ <Section kind="secret" items={data.secrets} scope={scope} manage={manage} action={action} />
143+ <Section kind="variable" items={data.variables} scope={scope} manage={manage} action={action} />
144+ </div>
145+ );
146+}
+10−1
2626 Settings,
2727 Users,
2828 Webhook,
29+ PlayCircle,
2930 Zap,
3031 X,
3132 } from "lucide-react";
244245 }
245246
246247 /** A workspace's settings pages, which the sidebar slides over to. */
247−const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks)(\/|$)/;
248+const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
248249
249250 /**
250251 * The sidebar's menus sit side by side on one track, and the track slides:
288289 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
289290 Webhooks
290291 </SidebarLink>
292+ <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
293+ Secrets and variables
294+ </SidebarLink>
291295 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
292296 Access tokens
293297 </SidebarLink>
461465 >
462466 Pull requests
463467 </SidebarLink>
468+ <SidebarLink to={`${repoBase}/actions`} icon={<PlayCircle size={14} />}>
469+ Actions
470+ </SidebarLink>
464471 <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}>
465472 Merge queue
466473 </SidebarLink>
515522 commits: "Commits",
516523 plans: "Plan",
517524 automations: "Automations",
525+ actions: "Actions",
526+ secrets: "Secrets and variables",
518527 settings: "Settings",
519528 people: "Members",
520529 tokens: "Access tokens",
+40−0
1+import type { Setting, SettingKind, SettingsOwner, User } from "@g1t/contracts";
2+
3+import { actions } from "./services.server";
4+
5+export type SecretsData = {
6+ secrets: Setting[];
7+ variables: Setting[];
8+ error: string | null;
9+};
10+
11+/** A repository's or a workspace's secrets and variables. */
12+export async function loadSecrets(owner: SettingsOwner, actor: User): Promise<SecretsData> {
13+ const [secrets, variables] = await Promise.all([
14+ actions.settings(actor, owner, "secret"),
15+ actions.settings(actor, owner, "variable"),
16+ ]);
17+ return {
18+ secrets: secrets.ok ? secrets.value : [],
19+ variables: variables.ok ? variables.value : [],
20+ error: !secrets.ok ? secrets.error.message : !variables.ok ? variables.error.message : null,
21+ };
22+}
23+
24+export type SecretsAction = { done?: string; error?: string; kind?: SettingKind };
25+
26+export async function actOnSecrets(owner: SettingsOwner, actor: User, form: FormData): Promise<SecretsAction> {
27+ const intent = String(form.get("intent") ?? "");
28+ const kind: SettingKind = form.get("kind") === "variable" ? "variable" : "secret";
29+ const name = String(form.get("name") ?? "").trim();
30+ const what = kind === "secret" ? "Secret" : "Variable";
31+ if (intent === "delete") {
32+ const removed = await actions.deleteSetting(actor, owner, kind, name);
33+ return removed.ok ? { done: `${what} ${name} removed.`, kind } : { error: removed.error.message, kind };
34+ }
35+ const value = String(form.get("value") ?? "");
36+ if (!name) return { error: "Give it a name.", kind };
37+ if (kind === "secret" && !value) return { error: "Give the secret a value.", kind };
38+ const saved = await actions.setSetting(actor, owner, kind, name, value);
39+ return saved.ok ? { done: `${what} ${saved.value.name} saved.`, kind } : { error: saved.error.message, kind };
40+}
+2−0
11 import { env } from "cloudflare:workers";
22
33 import {
4+ actionsClient,
45 automationsClient,
56 billingClient,
67 eventsClient,
1920 export const integrations = integrationsClient(env.INTEGRATIONS);
2021 export const webhooks = webhooksClient(env.WEBHOOKS);
2122 export const automations = automationsClient(env.AUTOMATIONS);
23+export const actions = actionsClient(env.ACTIONS);
+5−0
2424 route("-/billing", "routes/workspace/billing.tsx"),
2525 route("-/integrations", "routes/workspace/integrations.tsx"),
2626 route("-/webhooks", "routes/workspace/webhooks.tsx"),
27+ route("-/secrets", "routes/workspace/secrets.tsx"),
2728 route("-/settings", "routes/workspace/settings.tsx"),
2829 ]),
2930 // Why a line is the way it is, fetched by the blame view.
4142 route("pulls/new", "routes/repo/pull-new.tsx"),
4243 route("pull/:number", "routes/repo/pull.tsx"),
4344 route("queue", "routes/repo/queue.tsx"),
45+ route("actions", "routes/repo/actions.tsx"),
46+ route("actions/runs/:id", "routes/repo/actions-run.tsx"),
47+ route("actions/jobs/:job/log", "routes/repo/actions-log.ts"),
4448 route("automations", "routes/repo/automations.tsx"),
4549 route("plans", "routes/repo/plans.tsx"),
4650 route("plans/:id", "routes/repo/plan.tsx"),
4751 route("settings", "routes/repo/settings.tsx"),
4852 route("settings/webhooks", "routes/repo/webhooks.tsx"),
53+ route("settings/secrets", "routes/repo/secrets.tsx"),
4954 ]),
5055 // Anything else: a 404 that still knows who is signed in.
5156 route("*", "routes/not-found.tsx"),
+18−0
1+import { data } from "react-router";
2+
3+import type { Route } from "./+types/actions-log";
4+import { actions } from "../../lib/services.server";
5+import { getViewer } from "../../lib/session.server";
6+
7+/** A job's log after `?after=`, for the run page to fetch as it grows. */
8+export async function loader({ params, context, request }: Route.LoaderArgs) {
9+ const after = Number(new URL(request.url).searchParams.get("after") ?? 0);
10+ const log = await actions.logs(
11+ { namespace: params.owner, name: params.repo },
12+ getViewer(context),
13+ params.job,
14+ Number.isFinite(after) && after > 0 ? after : 0,
15+ );
16+ if (!log.ok) throw data({ error: log.error.message }, { status: log.error.code === "not_found" ? 404 : 400 });
17+ return Response.json(log.value, { headers: { "cache-control": "no-store" } });
18+}
+232−0
1+import { AlertTriangle, ChevronRight, GitBranch, GitCommitHorizontal, Info, RotateCw, Square, XCircle } from "lucide-react";
2+import { type ReactNode, useEffect } from "react";
3+import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router";
4+
5+import type { Annotation, Job, StepState } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/actions-run";
8+import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
9+import { Button, ErrorText, TimeAgo } from "../../components/ui";
10+import { actions } from "../../lib/services.server";
11+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
12+
13+export function meta({ loaderData, params }: Route.MetaArgs) {
14+ const run = loaderData?.detail.run;
15+ return [{ title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` }];
16+}
17+
18+export async function loader({ params, context }: Route.LoaderArgs) {
19+ const viewer = getViewer(context);
20+ const detail = unwrap(await actions.run({ namespace: params.owner, name: params.repo }, viewer, params.id));
21+ return { detail, member: roleIn(viewer, params.owner) != null };
22+}
23+
24+export async function action({ request, params, context }: Route.ActionArgs) {
25+ assertSameOrigin(request);
26+ const user = requireUser(context, request);
27+ const repo = { namespace: params.owner, name: params.repo };
28+ const intent = String((await request.formData()).get("intent"));
29+ const done =
30+ intent === "cancel"
31+ ? await actions.cancel(user, repo, params.id)
32+ : await actions.rerun(user, repo, params.id, intent === "rerun-failed");
33+ return done.ok ? {} : { error: done.error.message };
34+}
35+
36+const ANNOTATION_ICON: Record<Annotation["level"], ReactNode> = {
37+ error: <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />,
38+ warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
39+ notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
40+};
41+
42+function StepRow({ step, text, defaultOpen }: { step: StepState; text: string | undefined; defaultOpen: boolean }) {
43+ return (
44+ <details className="group border-t border-line first:border-t-0" open={defaultOpen}>
45+ <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
46+ <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
47+ <StatusIcon status={step.status} conclusion={step.conclusion} size={14} />
48+ <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span>
49+ <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(step.startedAt, step.finishedAt)}</span>
50+ </summary>
51+ <div className="border-t border-line bg-bg/60">
52+ {text === undefined ? (
53+ <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output yet."}</p>
54+ ) : (
55+ <LogText text={text} />
56+ )}
57+ </div>
58+ </details>
59+ );
60+}
61+
62+function JobView({ job, base }: { job: Job; base: string }) {
63+ const live = job.status !== "completed";
64+ const log = useJobLog(`${base}/actions/jobs/${job.id}/log`, live);
65+ const failed = job.steps.find((s) => s.conclusion === "failure");
66+ const running = job.steps.find((s) => s.status === "in_progress");
67+ return (
68+ <section className="min-w-0 space-y-4">
69+ <div className="flex flex-wrap items-center gap-3">
70+ <StatusIcon status={job.status} conclusion={job.conclusion} size={18} />
71+ <h3 className="text-base font-semibold">{job.name}</h3>
72+ <span className="text-sm text-muted">
73+ {standingWord(job)}
74+ {job.startedAt && ` · ${duration(job.startedAt, job.finishedAt)}`}
75+ </span>
76+ </div>
77+ {job.reason && <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">{job.reason}</p>}
78+ {job.annotations.length > 0 && (
79+ <ul className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
80+ {job.annotations.map((note, index) => (
81+ <li key={index} className="flex gap-2">
82+ {ANNOTATION_ICON[note.level]}
83+ <span className="min-w-0">
84+ {note.title && <span className="font-medium">{note.title}: </span>}
85+ <span className="whitespace-pre-wrap">{note.message}</span>
86+ {note.file && (
87+ <span className="block font-mono text-xs text-faint">
88+ {note.file}
89+ {note.line != null && `:${note.line}`}
90+ </span>
91+ )}
92+ </span>
93+ </li>
94+ ))}
95+ </ul>
96+ )}
97+ <div className="overflow-hidden rounded-xl border border-line bg-surface">
98+ <StepRow
99+ step={{ number: 0, name: "Set up job", status: job.startedAt ? "completed" : "queued", conclusion: job.startedAt ? "success" : null, startedAt: job.startedAt, finishedAt: job.startedAt }}
100+ text={log.get(0)}
101+ defaultOpen={false}
102+ />
103+ {job.steps.map((step) => (
104+ <StepRow
105+ key={step.number}
106+ step={step}
107+ text={log.get(step.number)}
108+ defaultOpen={step.number === (failed ?? running)?.number}
109+ />
110+ ))}
111+ </div>
112+ </section>
113+ );
114+}
115+
116+export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
117+ const { detail, member } = loaderData;
118+ const { run, jobs, notes } = detail;
119+ const base = `/${params.owner}/${params.repo}`;
120+ const [search] = useSearchParams();
121+ const busy = useNavigation().state === "submitting";
122+ const revalidator = useRevalidator();
123+ const live = run.status !== "completed";
124+ useEffect(() => {
125+ if (!live) return;
126+ const timer = setInterval(() => {
127+ if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
128+ }, 2500);
129+ return () => clearInterval(timer);
130+ }, [live, revalidator]);
131+
132+ // The job asked for, else one that failed, is running, or the first.
133+ const selected =
134+ jobs.find((job) => job.id === search.get("job")) ??
135+ jobs.find((job) => job.conclusion === "failure" && job.steps.length > 0) ??
136+ jobs.find((job) => job.status === "in_progress") ??
137+ jobs[0];
138+ const anyFailed = jobs.some((job) => job.conclusion === "failure" || job.conclusion === "cancelled");
139+
140+ return (
141+ <div className="max-w-6xl space-y-6">
142+ <header className="space-y-3">
143+ <Link to={`${base}/actions?workflow=${run.workflowId}`} className="text-sm text-muted hover:text-fg">
144+ {run.name}
145+ </Link>
146+ <div className="flex flex-wrap items-start justify-between gap-4">
147+ <h2 className="flex min-w-0 items-center gap-2.5 text-xl font-semibold tracking-tight">
148+ <StatusIcon status={run.status} conclusion={run.conclusion} size={20} />
149+ <span className="min-w-0 truncate">{run.title || run.name}</span>
150+ <span className="font-normal text-muted">#{run.number}</span>
151+ </h2>
152+ {member && !run.error && (
153+ <Form method="post" className="flex gap-2">
154+ {live ? (
155+ <Button type="submit" name="intent" value="cancel" variant="quiet" disabled={busy}>
156+ <Square size={13} />
157+ Cancel run
158+ </Button>
159+ ) : (
160+ <>
161+ {anyFailed && (
162+ <Button type="submit" name="intent" value="rerun-failed" variant="quiet" disabled={busy}>
163+ <RotateCw size={13} />
164+ Re-run failed jobs
165+ </Button>
166+ )}
167+ <Button type="submit" name="intent" value="rerun" variant="quiet" disabled={busy}>
168+ <RotateCw size={13} />
169+ Re-run all jobs
170+ </Button>
171+ </>
172+ )}
173+ </Form>
174+ )}
175+ </div>
176+ <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
177+ <span>{standingWord(run)}</span>
178+ <span className="inline-flex items-center gap-1 font-mono text-xs">
179+ <GitBranch size={12} />
180+ {shortRef(run.ref)}
181+ </span>
182+ <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
183+ <GitCommitHorizontal size={12} />
184+ {run.sha.slice(0, 7)}
185+ </Link>
186+ {run.pull != null && (
187+ <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg">
188+ #{run.pull}
189+ </Link>
190+ )}
191+ <span>
192+ {run.event}
193+ {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} />
194+ </span>
195+ {run.startedAt && <span className="font-mono text-xs">{duration(run.startedAt, run.finishedAt)}</span>}
196+ {run.attempt > 1 && <span>Attempt {run.attempt}</span>}
197+ </p>
198+ </header>
199+
200+ <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
201+ {run.error && (
202+ <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm">
203+ <p className="font-medium text-danger">The workflow file could not be used</p>
204+ <p className="mt-1 whitespace-pre-wrap text-fg/85">{run.error}</p>
205+ </div>
206+ )}
207+ <Notes notes={notes} />
208+
209+ {jobs.length > 0 && (
210+ <div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
211+ <nav aria-label="Jobs" className="space-y-0.5 text-sm">
212+ {jobs.map((job) => (
213+ <Link
214+ key={job.id}
215+ to={`?job=${job.id}`}
216+ preventScrollReset
217+ className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
218+ job.id === selected?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
219+ }`}
220+ >
221+ <StatusIcon status={job.status} conclusion={job.conclusion} size={14} />
222+ <span className="min-w-0 truncate">{job.name}</span>
223+ <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(job.startedAt, job.finishedAt)}</span>
224+ </Link>
225+ ))}
226+ </nav>
227+ {selected && <JobView key={selected.id} job={selected} base={base} />}
228+ </div>
229+ )}
230+ </div>
231+ );
232+}
+337−0
1+import { AlertTriangle, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
2+import { useEffect, useState } from "react";
3+import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router";
4+
5+import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/actions";
8+import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
9+import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
10+import { actions } from "../../lib/services.server";
11+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
12+
13+export function meta({ params }: Route.MetaArgs) {
14+ return [{ title: `Actions · ${params.owner}/${params.repo} · g1t` }];
15+}
16+
17+export async function loader({ params, context, request }: Route.LoaderArgs) {
18+ const viewer = getViewer(context);
19+ const repo = { namespace: params.owner, name: params.repo };
20+ const selected = new URL(request.url).searchParams.get("workflow") ?? undefined;
21+ const [workflows, runs] = await Promise.all([
22+ actions.workflows(repo, viewer),
23+ actions.runs(repo, viewer, { workflow: selected, limit: 50 }),
24+ ]);
25+ return {
26+ workflows: unwrap(workflows),
27+ runs: runs.ok ? runs.value : [],
28+ member: roleIn(viewer, params.owner) != null,
29+ selected: selected ?? null,
30+ };
31+}
32+
33+export async function action({ request, params, context }: Route.ActionArgs) {
34+ assertSameOrigin(request);
35+ const user = requireUser(context, request);
36+ const repo = { namespace: params.owner, name: params.repo };
37+ const form = await request.formData();
38+ const workflow = String(form.get("workflow") ?? "");
39+ if (form.get("intent") === "toggle") {
40+ const changed = await actions.setWorkflowEnabled(user, repo, workflow, form.get("enabled") === "true");
41+ return changed.ok ? {} : { error: changed.error.message };
42+ }
43+ const inputs: Record<string, unknown> = {};
44+ for (const [key, value] of form.entries()) {
45+ if (key.startsWith("input.")) inputs[key.slice(6)] = value === "on" ? true : value;
46+ }
47+ // An unticked checkbox sends nothing: it is false.
48+ for (const key of String(form.get("booleans") ?? "").split(",").filter(Boolean)) {
49+ if (!(key in inputs)) inputs[key] = false;
50+ }
51+ const ref = String(form.get("ref") ?? "").trim() || undefined;
52+ const started = await actions.dispatch(user, repo, workflow, ref, inputs);
53+ return started.ok ? { started: started.value } : { error: started.error.message };
54+}
55+
56+/** Re-reads the page every few seconds while something is still running. */
57+function useLiveWhile(running: boolean) {
58+ const revalidator = useRevalidator();
59+ useEffect(() => {
60+ if (!running) return;
61+ const timer = setInterval(() => {
62+ if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
63+ }, 3000);
64+ return () => clearInterval(timer);
65+ }, [running, revalidator]);
66+}
67+
68+const EVENT_WORDS: Record<string, string> = {
69+ push: "push",
70+ pull_request: "pull request",
71+ pull_request_target: "pull request (target)",
72+ pull_request_review: "review",
73+ issues: "issue",
74+ issue_comment: "comment",
75+ schedule: "schedule",
76+ workflow_dispatch: "manual",
77+};
78+
79+function RunRow({ run, base, showWorkflow }: { run: WorkflowRun; base: string; showWorkflow: boolean }) {
80+ return (
81+ <li className="border-t border-line first:border-t-0">
82+ <Link to={`${base}/actions/runs/${run.id}`} className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
83+ <span className="mt-0.5">
84+ <StatusIcon status={run.status} conclusion={run.conclusion} />
85+ </span>
86+ <span className="min-w-0 grow">
87+ <span className="block truncate text-sm font-medium">{run.title || run.name}</span>
88+ <span className="mt-0.5 block truncate text-xs text-muted">
89+ {showWorkflow && <span className="text-fg/80">{run.name}</span>}
90+ {showWorkflow && " · "}#{run.number}
91+ {run.attempt > 1 && ` (attempt ${run.attempt})`}
92+ {" · "}
93+ {EVENT_WORDS[run.event] ?? run.event}
94+ {run.actor && ` by ${run.actor}`}
95+ </span>
96+ </span>
97+ <span className="hidden shrink-0 items-center gap-1 rounded-md bg-raised px-1.5 py-0.5 font-mono text-xs text-muted sm:inline-flex">
98+ <GitBranch size={11} />
99+ {shortRef(run.ref)}
100+ </span>
101+ <span className="w-28 shrink-0 text-right text-xs text-faint">
102+ <TimeAgo at={run.createdAt} />
103+ {run.startedAt && <span className="block font-mono">{duration(run.startedAt, run.finishedAt)}</span>}
104+ </span>
105+ </Link>
106+ </li>
107+ );
108+}
109+
110+function InputField({ name, spec }: { name: string; spec: DispatchInput }) {
111+ const label = (
112+ <span className="mb-1 block text-xs font-medium text-muted">
113+ {spec.description || name}
114+ {spec.required && <span className="text-danger"> *</span>}
115+ </span>
116+ );
117+ const control = "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-accent-dim";
118+ if (spec.type === "boolean") {
119+ return (
120+ <label className="flex items-center gap-2 text-sm">
121+ <input type="checkbox" name={`input.${name}`} defaultChecked={spec.default === true || spec.default === "true"} />
122+ {spec.description || name}
123+ </label>
124+ );
125+ }
126+ if (spec.type === "choice" && spec.options) {
127+ return (
128+ <label className="block">
129+ {label}
130+ <select name={`input.${name}`} defaultValue={String(spec.default ?? spec.options[0] ?? "")} className={control}>
131+ {spec.options.map((option) => (
132+ <option key={option}>{option}</option>
133+ ))}
134+ </select>
135+ </label>
136+ );
137+ }
138+ return (
139+ <label className="block">
140+ {label}
141+ <input
142+ name={`input.${name}`}
143+ required={spec.required}
144+ defaultValue={spec.default == null ? "" : String(spec.default)}
145+ type={spec.type === "number" ? "number" : "text"}
146+ className={control}
147+ autoComplete="off"
148+ />
149+ </label>
150+ );
151+}
152+
153+function RunWorkflow({ workflow }: { workflow: Workflow }) {
154+ const [open, setOpen] = useState(false);
155+ const busy = useNavigation().state === "submitting";
156+ const inputs = Object.entries(workflow.dispatch ?? {});
157+ const booleans = inputs.filter(([, spec]) => spec.type === "boolean").map(([name]) => name);
158+ return (
159+ <div className="relative">
160+ <Button type="button" variant="quiet" onClick={() => setOpen((v) => !v)}>
161+ <Play size={14} />
162+ Run workflow
163+ </Button>
164+ {open && (
165+ <Form
166+ method="post"
167+ onSubmit={() => setOpen(false)}
168+ className="absolute right-0 z-20 mt-2 w-80 space-y-3 rounded-xl border border-line bg-surface p-4 shadow-xl"
169+ >
170+ <input type="hidden" name="workflow" value={workflow.id} />
171+ <input type="hidden" name="booleans" value={booleans.join(",")} />
172+ <label className="block">
173+ <span className="mb-1 block text-xs font-medium text-muted">Branch or tag</span>
174+ <input
175+ name="ref"
176+ placeholder="The default branch"
177+ className="w-full rounded-md border border-line bg-bg px-2.5 py-1.5 font-mono text-sm outline-none focus:border-accent-dim"
178+ autoComplete="off"
179+ />
180+ </label>
181+ {inputs.map(([name, spec]) => (
182+ <InputField key={name} name={name} spec={spec} />
183+ ))}
184+ <Button type="submit" disabled={busy}>
185+ <PlayCircle size={14} />
186+ Run
187+ </Button>
188+ </Form>
189+ )}
190+ </div>
191+ );
192+}
193+
194+function WorkflowHeader({ workflow, base, member }: { workflow: Workflow; base: string; member: boolean }) {
195+ const busy = useNavigation().state === "submitting";
196+ return (
197+ <div className="space-y-4">
198+ <div className="flex flex-wrap items-start justify-between gap-3">
199+ <div className="min-w-0">
200+ <h3 className="text-lg font-semibold tracking-tight">{workflow.name}</h3>
201+ <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg">
202+ <FileCode2 size={12} />
203+ {workflow.path}
204+ </Link>
205+ {workflow.events.length > 0 && (
206+ <p className="mt-1.5 text-xs text-muted">On {workflow.events.map((e) => EVENT_WORDS[e] ?? e).join(", ")}</p>
207+ )}
208+ </div>
209+ {member && !workflow.error && (
210+ <div className="flex items-center gap-2">
211+ {workflow.dispatch && workflow.state === "active" && <RunWorkflow workflow={workflow} />}
212+ <Form method="post">
213+ <input type="hidden" name="intent" value="toggle" />
214+ <input type="hidden" name="workflow" value={workflow.id} />
215+ <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} />
216+ <Button type="submit" variant="quiet" disabled={busy}>
217+ {workflow.state === "active" ? "Turn off" : "Turn on"}
218+ </Button>
219+ </Form>
220+ </div>
221+ )}
222+ </div>
223+ {workflow.error && <p className="rounded-lg bg-danger/10 px-3 py-2 text-sm text-danger">{workflow.error}</p>}
224+ {workflow.state === "disabled" && (
225+ <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">Turned off: nothing starts it until a member turns it on.</p>
226+ )}
227+ <Notes notes={workflow.notes} />
228+ </div>
229+ );
230+}
231+
232+const EXAMPLE = `name: CI
233+on:
234+ push:
235+ branches: [main]
236+ pull_request:
237+jobs:
238+ test:
239+ runs-on: ubuntu-latest
240+ steps:
241+ - uses: actions/checkout@v4
242+ - uses: actions/setup-node@v4
243+ with:
244+ node-version: 22
245+ - run: npm ci
246+ - run: npm test`;
247+
248+export default function Actions({ loaderData, actionData, params }: Route.ComponentProps) {
249+ const { workflows, runs, member, selected } = loaderData;
250+ const [search] = useSearchParams();
251+ const base = `/${params.owner}/${params.repo}`;
252+ const workflow = workflows.find((w) => w.id === selected || w.path.endsWith(`/${selected}`)) ?? null;
253+ useLiveWhile(runs.some((run) => run.status !== "completed"));
254+ const started = actionData && "started" in actionData ? actionData.started : null;
255+ return (
256+ <div className="max-w-6xl">
257+ <header className="mb-6 flex flex-wrap items-start justify-between gap-4">
258+ <div>
259+ <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
260+ <PlayCircle size={18} className="text-accent" />
261+ Actions
262+ </h2>
263+ <p className="mt-1 max-w-2xl text-sm text-muted">
264+ GitHub Actions workflows in <code className="text-fg">.g1t/workflows</code>, run as GitHub runs them. A run on
265+ a pull request counts as its checks.
266+ </p>
267+ </div>
268+ <a href="https://docs.g1t.sh/guides/actions/" className="text-sm text-muted underline underline-offset-4 hover:text-fg">
269+ How Actions run on g1t
270+ </a>
271+ </header>
272+
273+ {workflows.length === 0 ? (
274+ <div className="grid gap-6 lg:grid-cols-2">
275+ <EmptyState title="No workflows yet">
276+ Coming from GitHub? Rename <code>.github</code> to <code>.g1t</code> and push: your workflows run here as they
277+ are. g1t never reads <code>.github</code>.
278+ </EmptyState>
279+ <div>
280+ <p className="font-mono text-xs text-faint">.g1t/workflows/ci.yml</p>
281+ <pre className="mt-1.5 rounded-lg bg-surface p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap ring-1 ring-line">
282+ <code>{EXAMPLE}</code>
283+ </pre>
284+ </div>
285+ </div>
286+ ) : (
287+ <div className="grid gap-8 lg:grid-cols-[14rem_1fr]">
288+ <nav aria-label="Workflows" className="space-y-0.5 text-sm">
289+ <Link
290+ to="?"
291+ className={`block rounded-md px-2.5 py-1.5 ${!selected ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}
292+ >
293+ All workflows
294+ </Link>
295+ {workflows.map((w) => (
296+ <Link
297+ key={w.id}
298+ to={`?workflow=${w.id}`}
299+ className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
300+ w.id === workflow?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
301+ }`}
302+ >
303+ <span className="min-w-0 truncate">{w.name}</span>
304+ {w.error && <AlertTriangle size={12} className="shrink-0 text-danger" aria-label="Its file has a problem" />}
305+ {w.state === "disabled" && <span className="ml-auto shrink-0 text-xs text-faint">off</span>}
306+ </Link>
307+ ))}
308+ </nav>
309+ <div className="min-w-0 space-y-6">
310+ {workflow && <WorkflowHeader workflow={workflow} base={base} member={member} />}
311+ {started && (
312+ <p className="text-sm text-muted">
313+ Started{" "}
314+ <Link to={`${base}/actions/runs/${started.id}`} className="text-fg underline underline-offset-4">
315+ run #{started.number}
316+ </Link>
317+ .
318+ </p>
319+ )}
320+ <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
321+ {runs.length === 0 ? (
322+ <EmptyState title="No runs yet">
323+ {workflow?.dispatch ? "Run it by hand, or wait for what starts it." : "Runs appear here when something starts one."}
324+ </EmptyState>
325+ ) : (
326+ <ul className="overflow-hidden rounded-xl border border-line bg-surface">
327+ {runs.map((run) => (
328+ <RunRow key={run.id} run={run} base={base} showWorkflow={!search.get("workflow")} />
329+ ))}
330+ </ul>
331+ )}
332+ </div>
333+ </div>
334+ )}
335+ </div>
336+ );
337+}
+5−1
3939 Textarea,
4040 TimeAgo,
4141 } from "../../components/ui";
42+import { WorkflowStatuses } from "../../components/actions";
4243 import { ChecksPanel } from "../../components/checks";
4344 import {
4445 CommentForm,
318319 reviewPending,
319320 lifecycle,
320321 messages,
322+ statuses = [],
321323 landing,
322324 stalled,
323325 requireUpToDate,
345347 // Follow an agent at work, or checks in progress, without a manual reload.
346348 const revalidator = useRevalidator();
347349 const working = pull.status === "draft";
348− const checking = checks?.status === "queued" || checks?.status === "running";
350+ const checking =
351+ checks?.status === "queued" || checks?.status === "running" || statuses.some((status) => status.state === "pending");
349352 const reviews = verdicts(comments);
350353 // What stands between this pull request and a merge, if anything.
351354 const unchecked = checks && checks.status !== "passed";
858861 canRerun={canManage && pull.status === "open"}
859862 />
860863 {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>}
864+ <WorkflowStatuses statuses={statuses} />
861865
862866 <section>
863867 <h3 className="text-sm font-medium">Reviewers</h3>
+32−0
1+import type { Route } from "./+types/secrets";
2+import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
3+import { SecretsPanel } from "../../components/secrets";
4+import { actOnSecrets, loadSecrets } from "../../lib/secrets.server";
5+import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
6+
7+export function meta({ params }: Route.MetaArgs) {
8+ return [{ title: `Secrets and variables · ${params.owner}/${params.repo} · g1t` }];
9+}
10+
11+const ownerOf = (params: { owner: string; repo: string }) => ({ repo: { namespace: params.owner, name: params.repo } });
12+
13+export async function loader({ params, context, request }: Route.LoaderArgs) {
14+ // A repository's settings are its workspace's members' to see.
15+ if (!roleIn(getViewer(context), params.owner)) throw new Response(null, { status: 404 });
16+ return loadSecrets(ownerOf(params), requireUser(context, request));
17+}
18+
19+export async function action({ request, params, context }: Route.ActionArgs) {
20+ assertSameOrigin(request);
21+ const user = requireUser(context, request);
22+ return actOnSecrets(ownerOf(params), user, await request.formData());
23+}
24+
25+export default function RepoSecrets({ loaderData, actionData, params }: Route.ComponentProps) {
26+ return (
27+ <div>
28+ <RepoSettingsTabs base={`/${params.owner}/${params.repo}`} />
29+ <SecretsPanel data={loaderData} action={actionData} scope="repository" manage />
30+ </div>
31+ );
32+}
+4−0
3333 title: "Webhooks",
3434 about: "Every repository's events, sent to your own addresses as they happen. A repository can also have its own, under its settings.",
3535 },
36+ secrets: {
37+ title: "Secrets and variables",
38+ about: "What every repository's GitHub Actions workflows read as secrets and vars. A repository's own, under its settings, replace these by name.",
39+ },
3640 integrations: {
3741 title: "Integrations",
3842 about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.",
+26−0
1+import type { Route } from "./+types/secrets";
2+import { SecretsPanel } from "../../components/secrets";
3+import { actOnSecrets, loadSecrets } from "../../lib/secrets.server";
4+import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
5+
6+export function meta({ params }: Route.MetaArgs) {
7+ return [{ title: `Secrets and variables · ${params.owner} · g1t` }];
8+}
9+
10+export async function loader({ params, context, request }: Route.LoaderArgs) {
11+ const role = roleIn(getViewer(context), params.owner);
12+ if (!role) throw new Response(null, { status: 404 });
13+ return { role, ...(await loadSecrets({ workspace: params.owner.toLowerCase() }, requireUser(context, request))) };
14+}
15+
16+export async function action({ request, params, context }: Route.ActionArgs) {
17+ assertSameOrigin(request);
18+ const user = requireUser(context, request);
19+ return actOnSecrets({ workspace: params.owner.toLowerCase() }, user, await request.formData());
20+}
21+
22+export default function WorkspaceSecrets({ loaderData, actionData }: Route.ComponentProps) {
23+ const { role, ...data } = loaderData;
24+ // Every repository reads a workspace's, so only its owners change them.
25+ return <SecretsPanel data={data} action={actionData} scope="workspace" manage={role === "owner"} />;
26+}
+15−0
194194 `update_webhook`, `delete_webhook`, `ping_webhook`,
195195 `list_webhook_deliveries`, `redeliver_webhook`, `list_automations`,
196196 `list_automation_runs`, `run_automation`, `update_automation`,
197+`list_workflows`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`,
198+`dispatch_workflow`, `cancel_workflow_run`, `rerun_workflow_run`,
199+`update_workflow`, `list_actions_secrets`, `set_actions_secret`,
200+`delete_actions_secret`, `list_actions_variables`, `set_actions_variable`,
201+`delete_actions_variable`,
197202 `list_repos`, `get_repo`, `create_repo`, `update_repo`,
198203 `get_repo_settings`, `update_repo_settings`, `list_events`,
199204 `create_workspace`, and `whoami`. MCP tools take the repository as `repo`,
228233 (HMAC-SHA256 of the body), retried for about seven hours. Deliveries,
229234 with request and response, are at `…/hooks/{id}/deliveries`.
230235
236+## GitHub Actions
237+
238+GitHub Actions workflows run on g1t unchanged, from `.g1t/workflows/`
239+(g1t never reads `.github`): moving a repository is `git mv .github .g1t`.
240+Runs, jobs and logs are at GitHub's own routes under
241+`{repo}/actions/...`. A run on a pull request's head is a check: pending
242+holds the merge, failure refuses it and sends a g1t agent back to fix it.
243+Secrets and variables: `{repo}/actions/secrets`, `{repo}/actions/variables`.
244+
231245 ## Automations
232246
233247 A YAML file in `.g1t/automations/` on the default branch is a rule: `on`
275289 - [Integrations](https://docs.g1t.sh/guides/integrations/)
276290 - [Model providers](https://docs.g1t.sh/guides/models/)
277291 - [Webhooks](https://docs.g1t.sh/guides/webhooks/)
292+- [GitHub Actions](https://docs.g1t.sh/guides/actions/)
278293 - [Automations](https://docs.g1t.sh/guides/automations/)
279294 - [Usage and billing](https://docs.g1t.sh/guides/usage-and-billing/)
280295 - [Git](https://docs.g1t.sh/guides/git/)
+1−0
1313 INTEGRATIONS: ServiceBinding;
1414 WEBHOOKS: ServiceBinding;
1515 AUTOMATIONS: ServiceBinding;
16+ ACTIONS: ServiceBinding;
1617 }
1718 }
1819 interface Env extends Cloudflare.Env {}
+2−1
1818 { "binding": "EVENTS", "service": "g1t-events" },
1919 { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
2020 { "binding": "WEBHOOKS", "service": "g1t-webhooks" },
21− { "binding": "AUTOMATIONS", "service": "g1t-automations" }
21+ { "binding": "AUTOMATIONS", "service": "g1t-automations" },
22+ { "binding": "ACTIONS", "service": "g1t-actions" }
2223 ],
2324 "observability": { "enabled": true },
2425 "upload_source_maps": true
+1−1
5151 pub run_attempt: u64,
5252 /// The workflow's name.
5353 pub workflow: String,
54− /// `.github/workflows/ci.yml`.
54+ /// `.g1t/workflows/ci.yml`.
5555 pub workflow_path: String,
5656 pub server_url: String,
5757 pub api_url: String,
+1−1
11 //! The GitHub Actions expression language: the `${{ }}` language.
22 //!
33 //! This follows GitHub's "Evaluate expressions in workflows and actions"
4−//! precisely, so a real `.github/workflows/*.yml` evaluates here the way it
4+//! precisely, so a real GitHub workflow evaluates here the way it
55 //! does on GitHub: the same literals, the same operator precedence, the same
66 //! loose equality (with its coercions to number), the same case-insensitive
77 //! string handling, the same object filters (`labels.*.name`) and the same
+2−1
1−//! GitHub Actions on g1t. A repository's `.github/workflows/*.yml` run on
1+//! GitHub Actions on g1t. A repository's `.g1t/workflows/*.yml`, written
2+//! exactly as GitHub's `.github/workflows`, run on
23 //! g1t as they are: this crate reads them ([`workflow`]), evaluates their
34 //! `${{ }}` expressions ([`expr`]), matches their branch and path filters
45 //! ([`filter`]), expands their matrices ([`matrix`]), and says which g1t
+4−2
77
88 use crate::filter::{Filter, Patterns};
99
10−/// Where workflows live.
11−pub const FOLDER: &str = ".github/workflows";
10+/// Where workflows live: GitHub's `.github/workflows`, under g1t's own
11+/// folder, so moving a repository to g1t is renaming `.github` to `.g1t`.
12+/// g1t never reads `.github`, which stays GitHub's.
13+pub const FOLDER: &str = ".g1t/workflows";
1214
1315 /// The events a workflow can name that g1t starts runs for.
1416 pub const SUPPORTED_EVENTS: &[&str] = &[
+3−2
11 //! The actions service: GitHub Actions workflows, run on g1t as they are.
22 //!
3−//! A repository's `.github/workflows/*.yml` are read from the commit an
3+//! A repository's `.g1t/workflows/*.yml`, in GitHub's format, are read
4+//! from the commit an
45 //! event is about (the default branch for issues, schedules and manual
56 //! runs). Each workflow an event starts becomes a run; each job of the run
67 //! (one per matrix combination) runs in a sandbox once the jobs it needs
3334 #[serde(rename_all = "camelCase")]
3435 pub struct Workflow {
3536 pub id: String,
36− /// `.github/workflows/ci.yml`.
37+ /// `.g1t/workflows/ci.yml`.
3738 pub path: String,
3839 pub name: String,
3940 /// The events that start it, such as `push` and `pull_request`.
+9−1
225225 _ => {}
226226 }
227227 let source = if let Some(local) = name.strip_prefix("./") {
228− Source::Local(self.workspace.join(local))
228+ // A repository moved from GitHub renamed `.github` to `.g1t`, but
229+ // its workflows still say `./.github/actions/…`.
230+ let mut dir = self.workspace.join(local);
231+ if let Some(rest) = local.strip_prefix(".github/")
232+ && !dir.exists()
233+ {
234+ dir = self.workspace.join(".g1t").join(rest);
235+ }
236+ Source::Local(dir)
229237 } else {
230238 let mut parts = name.splitn(3, '/');
231239 let (Some(owner), Some(repo)) = (parts.next(), parts.next()) else {
+136−0
1+import type { User, Viewer } from "./identity";
2+import type { RepoPath } from "./repos";
3+import type { Result } from "./result";
4+
5+/**
6+ * GitHub Actions workflows, run on g1t as they are. Mirrors
7+ * `crates/contracts/src/actions.rs`.
8+ */
9+
10+export type WorkflowNote = {
11+ severity: "info" | "warning" | "unsupported";
12+ job: string | null;
13+ message: string;
14+};
15+
16+/** One `workflow_dispatch` input, as written in the workflow. */
17+export type DispatchInput = {
18+ description?: string;
19+ required?: boolean;
20+ default?: string | number | boolean;
21+ type?: "string" | "boolean" | "number" | "choice" | "environment";
22+ options?: string[];
23+};
24+
25+export type RunStatus = "pending" | "queued" | "in_progress" | "completed";
26+export type Conclusion = "success" | "failure" | "cancelled" | "skipped";
27+
28+export type WorkflowRun = {
29+ id: string;
30+ workflowId: string;
31+ path: string;
32+ name: string;
33+ title: string;
34+ number: number;
35+ attempt: number;
36+ event: string;
37+ ref: string;
38+ sha: string;
39+ pull: number | null;
40+ status: RunStatus;
41+ conclusion: Conclusion | null;
42+ error: string | null;
43+ actor: string | null;
44+ createdAt: string;
45+ startedAt: string | null;
46+ finishedAt: string | null;
47+};
48+
49+export type Workflow = {
50+ id: string;
51+ path: string;
52+ name: string;
53+ events: string[];
54+ state: "active" | "disabled";
55+ error: string | null;
56+ notes: WorkflowNote[];
57+ dispatch: Record<string, DispatchInput> | null;
58+ lastRun: WorkflowRun | null;
59+};
60+
61+export type StepState = {
62+ number: number;
63+ name: string;
64+ status: "queued" | "in_progress" | "completed";
65+ conclusion: Conclusion | null;
66+ startedAt: string | null;
67+ finishedAt: string | null;
68+};
69+
70+export type Annotation = {
71+ level: "error" | "warning" | "notice";
72+ message: string;
73+ title: string | null;
74+ file: string | null;
75+ line: number | null;
76+};
77+
78+export type Job = {
79+ id: string;
80+ runId: string;
81+ key: string;
82+ name: string;
83+ needs: string[];
84+ status: "waiting" | "queued" | "in_progress" | "completed";
85+ conclusion: Conclusion | null;
86+ steps: StepState[];
87+ annotations: Annotation[];
88+ reason: string | null;
89+ startedAt: string | null;
90+ finishedAt: string | null;
91+};
92+
93+export type RunDetail = { run: WorkflowRun; jobs: Job[]; notes: WorkflowNote[] };
94+
95+export type LogChunk = { seq: number; step: number; text: string };
96+export type JobLog = { chunks: LogChunk[]; done: boolean };
97+
98+export type Setting = {
99+ name: string;
100+ /** Variables only. */
101+ value: string | null;
102+ scope: "repository" | "workspace";
103+ updatedAt: string;
104+};
105+
106+export type SettingsOwner = { repo: RepoPath } | { workspace: string };
107+export type SettingKind = "secret" | "variable";
108+
109+export type RunsFilter = {
110+ workflow?: string;
111+ branch?: string;
112+ event?: string;
113+ pull?: number;
114+ sha?: string;
115+ limit?: number;
116+};
117+
118+export interface ActionsApi {
119+ workflows(repo: RepoPath, viewer: Viewer): Promise<Result<Workflow[]>>;
120+ runs(repo: RepoPath, viewer: Viewer, filter?: RunsFilter): Promise<Result<WorkflowRun[]>>;
121+ run(repo: RepoPath, viewer: Viewer, id: string): Promise<Result<RunDetail>>;
122+ logs(repo: RepoPath, viewer: Viewer, job: string, after?: number): Promise<Result<JobLog>>;
123+ dispatch(
124+ actor: User,
125+ repo: RepoPath,
126+ workflow: string,
127+ ref: string | undefined,
128+ inputs: Record<string, unknown>,
129+ ): Promise<Result<WorkflowRun>>;
130+ cancel(actor: User, repo: RepoPath, id: string): Promise<Result<WorkflowRun>>;
131+ rerun(actor: User, repo: RepoPath, id: string, failedOnly?: boolean): Promise<Result<WorkflowRun>>;
132+ setWorkflowEnabled(actor: User, repo: RepoPath, workflow: string, enabled: boolean): Promise<Result<Workflow>>;
133+ settings(actor: User, owner: SettingsOwner, kind: SettingKind): Promise<Result<Setting[]>>;
134+ setSetting(actor: User, owner: SettingsOwner, kind: SettingKind, name: string, value: string): Promise<Result<Setting>>;
135+ deleteSetting(actor: User, owner: SettingsOwner, kind: SettingKind, name: string): Promise<Result<boolean>>;
136+}
+19−0
1+import type { ActionsApi } from "./actions";
12 import type { AutomationsApi } from "./automations";
23 import type { BillingApi } from "./billing";
34 import type { EventsApi } from "./events";
231232 };
232233 }
233234
235+export function actionsClient(service: ServiceBinding): ActionsApi {
236+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
237+ return {
238+ workflows: (repo, viewer) => call("workflows", { repo, viewer }),
239+ runs: (repo, viewer, filter = {}) => call("runs", { repo, viewer, ...filter }),
240+ run: (repo, viewer, id) => call("run", { repo, viewer, id }),
241+ logs: (repo, viewer, job, after = 0) => call("logs", { repo, viewer, job, after }),
242+ dispatch: (actor, repo, workflow, ref, inputs) => call("dispatch", { actor, repo, workflow, ref, inputs }),
243+ cancel: (actor, repo, id) => call("cancel", { actor, repo, id }),
244+ rerun: (actor, repo, id, failedOnly = false) => call("rerun", { actor, repo, id, failed_only: failedOnly }),
245+ setWorkflowEnabled: (actor, repo, workflow, enabled) =>
246+ call("set_workflow_enabled", { actor, repo, workflow, enabled }),
247+ settings: (actor, owner, kind) => call("settings", { actor, ...owner, kind }),
248+ setSetting: (actor, owner, kind, name, value) => call("set_setting", { actor, ...owner, kind, name, value }),
249+ deleteSetting: (actor, owner, kind, name) => call("delete_setting", { actor, ...owner, kind, name }),
250+ };
251+}
252+
234253 export function automationsClient(service: ServiceBinding): AutomationsApi {
235254 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
236255 return {
+1−0
1+export * from "./actions";
12 export * from "./automations";
23 export * from "./billing";
34 export * from "./clients";
+12−0
287287 stalled: string | null;
288288 /** Messages people sent the agent while it worked, oldest first. */
289289 messages: AgentMessage[];
290+ /** What workflow runs said about its head commit, one per workflow. */
291+ statuses?: CommitStatus[];
292+};
293+
294+/** What a workflow run (or another tool) says about a commit. */
295+export type CommitStatus = {
296+ /** What reported it, such as `CI / push`. */
297+ context: string;
298+ state: "pending" | "success" | "failure" | "error";
299+ description: string | null;
300+ targetUrl: string | null;
301+ updatedAt: string;
290302 };
291303
292304 /**
+5−4
11301130 }
11311131 #[derive(Deserialize)]
11321132 struct Size {
1133− n: Option<u64>,
1134− seq: Option<u64>,
1133+ n: Option<f64>,
1134+ seq: Option<f64>,
11351135 }
11361136 let size = self
11371137 .db
11391139 .bind(&[job.id.as_str().into()])?
11401140 .first::<Size>(None)
11411141 .await?;
1142− let (used, seq) = size.map_or((0, 0), |s| (s.n.unwrap_or(0) as usize, s.seq.unwrap_or(0)));
1142+ let (used, seq) = size.map_or((0, 0.0), |s| (s.n.unwrap_or(0.0) as usize, s.seq.unwrap_or(0.0)));
11431143 if used < MAX_LOG_BYTES {
11441144 if used + text.len() >= MAX_LOG_BYTES {
11451145 text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n");
11461146 }
11471147 self.db
11481148 .prepare("INSERT INTO logs (job_id, seq, step, text) VALUES (?, ?, ?, ?)")
1149− .bind(&[job.id.as_str().into(), (seq + 1).into(), (report["step"].as_u64().unwrap_or(0) as u32).into(), text.into()])?
1149+ .bind(&[job.id.as_str().into(), // Numbers go to D1 as f64: a u64 would be a BigInt, which it refuses.
1150+ (seq + 1.0).into(), (report["step"].as_u64().unwrap_or(0) as u32).into(), text.into()])?
11501151 .run()
11511152 .await?;
11521153 }
+1−1
512512 let Some(sha) = read.head.clone() else {
513513 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
514514 };
515− let wanted = a.workflow.trim_start_matches(".github/workflows/");
515+ let wanted = a.workflow.trim_start_matches(".g1t/workflows/");
516516 let Some(file) = read.files.iter().find(|file| {
517517 file.path.rsplit('/').next() == Some(wanted) || file.path == a.workflow
518518 }) else {
+1−1
194194 if let Some(Outcome::Fail(refused)) = Self::member(&a.actor, &a.repo) {
195195 return Ok(Outcome::Fail(refused));
196196 }
197− let wanted = a.workflow.trim_start_matches(".github/workflows/");
197+ let wanted = a.workflow.trim_start_matches(".g1t/workflows/");
198198 let row = self
199199 .db
200200 .prepare("SELECT * FROM workflows WHERE lower(repo) = lower(?) AND (id = ? OR path = ?)")
+24−14
392392 const args = (await request.json()) as { job: string };
393393 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`actions:${args.job}`));
394394 await sandbox.destroy().catch(() => undefined);
395− return Response.json(ok(null));
395+ return Response.json(ok(true));
396396 }
397397 if (request.method === "POST" && pathname === "/rpc/plan") {
398398 const args = (await request.json()) as { actor: User; repo: RepoPath; brief: string };
554554 token: string;
555555 repo: RepoPath;
556556 timeoutMinutes: number;
557− }): Promise<Result<null>> {
557+ }): Promise<Result<true>> {
558558 const status = await billingClient(this.env.BILLING).status();
559559 if (!(this.previewListed(args.repo.namespace) || (status.enabled && status.live))) {
560560 return {
566566 };
567567 }
568568 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`actions:${args.job}`));
569− await sandbox.run({
570− kind: "actions",
571− jobId: args.job,
572− token: args.token,
573− envVars: {
574− MODE: "actions",
575− G1T_API: "https://api.g1t.sh",
576− ACTIONS_JOB: args.job,
577− ACTIONS_TOKEN: args.token,
578− },
579− });
580− return ok(null);
569+ try {
570+ await sandbox.run({
571+ kind: "actions",
572+ jobId: args.job,
573+ token: args.token,
574+ envVars: {
575+ MODE: "actions",
576+ G1T_API: "https://api.g1t.sh",
577+ ACTIONS_JOB: args.job,
578+ ACTIONS_TOKEN: args.token,
579+ },
580+ });
581+ } catch (error) {
582+ // A sandbox that could not start, or stopped at once: the job fails
583+ // with why, rather than waiting to be noticed.
584+ return {
585+ ok: false,
586+ error: { code: "conflict", message: `The runner could not start the job: ${String(error).replace(/^Error: /, "")}` },
587+ };
588+ }
589+ // `true`, not null: an outcome needs a value.
590+ return ok(true);
581591 }
582592
583593 /** Whether a workspace's repositories may use g1t's agents and sandboxes at all. */