diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..4f9059b --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,239 @@ +name: E2E + +on: + workflow_dispatch: + inputs: + spinloop_ref: + description: The spinloop-ai/spinloop ref to build + type: string + default: main + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/checkout@v7 + with: + repository: spinloop-ai/spinloop + ref: ${{ inputs.spinloop_ref }} + path: spinloop-src + + - uses: actions/setup-go@v7 + with: + go-version-file: spinloop-src/go.mod + cache: true + + - name: Build spinloop + working-directory: spinloop-src + run: | + mkdir -p ../spinloop-bin + go build -o ../spinloop-bin/spinloop ./cmd/spinloop + + - name: Start the stub gateway + run: | + set -euo pipefail + cat > stub-gateway.js <<'EOF' + // A fleet with no nodes: the orchestrator has a gateway that + // answers, so its run stays up, and nothing is ever admitted. + const http = require("http"); + http + .createServer((req, res) => { + if (req.url === "/v1/fleet") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ wake: false, prefer: "", nodes: [] })); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { message: "not served", type: "stub" } })); + }) + .listen(4101, "127.0.0.1"); + EOF + node stub-gateway.js & + echo $! > stub-gateway.pid + for _ in $(seq 1 50); do + if curl -fsS http://127.0.0.1:4101/v1/fleet > /dev/null 2>&1; then + exit 0 + fi + sleep 0.1 + done + echo "the stub gateway never came up" >&2 + exit 1 + + - name: Start the orchestrator + run: | + set -euo pipefail + printf '[]\n' > work.yaml + export OPENAI_API_KEY=e2e-gateway-token + ./spinloop-bin/spinloop orchestrator -H opencode --items work.yaml \ + --gateway http://127.0.0.1:4101 \ + --listen 127.0.0.1:4110 \ + --api-token e2e-token > orchestrator.log 2>&1 & + echo $! > orchestrator.pid + for _ in $(seq 1 50); do + if curl -fsS -H "Authorization: Bearer e2e-token" http://127.0.0.1:4110/health > /dev/null 2>&1; then + exit 0 + fi + if ! kill -0 "$(cat orchestrator.pid)" 2>/dev/null; then + echo "the orchestrator died at startup:" >&2 + cat orchestrator.log >&2 + exit 1 + fi + sleep 0.2 + done + echo "the work list API never came up:" >&2 + cat orchestrator.log >&2 + exit 1 + + - name: The work list is empty before the event + run: | + set -euo pipefail + LIST="$(curl -fsS -H "Authorization: Bearer e2e-token" http://127.0.0.1:4110/v1/items)" + [ "$(printf '%s' "$LIST" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(String(JSON.parse(d).data.length)))')" = "0" ] + + - name: Create the work issue + id: work_issue + env: + GH_TOKEN: ${{ github.token }} + run: | + url="$(gh issue create --title 'e2e work item' --body 'resolve this')" + echo "number=${url##*/}" >> "$GITHUB_OUTPUT" + + - name: Create the not-work issue + id: not_work_issue + env: + GH_TOKEN: ${{ github.token }} + run: | + url="$(gh issue create --title 'e2e not work' --body 'leave this alone')" + echo "number=${url##*/}" >> "$GITHUB_OUTPUT" + + - name: An opened issue adds an item + uses: ./work-items + with: + event: opened + binary: ./spinloop-bin/spinloop + url: http://127.0.0.1:4110 + token: e2e-token + id: ${{ steps.work_issue.outputs.number }} + issue-number: ${{ steps.work_issue.outputs.number }} + issue-title: e2e work item + issue-body: resolve this + dir: . + + - name: Assert the item was added + run: | + set -euo pipefail + LIST="$(curl -fsS -H "Authorization: Bearer e2e-token" http://127.0.0.1:4110/v1/items)" + printf '%s\n' "$LIST" + grep -q "\"id\":\"${{ steps.work_issue.outputs.number }}\"" <<< "$LIST" + grep -q "\"state\":\"backlog\"" <<< "$LIST" + + - name: A repeated open is a no-op + uses: ./work-items + with: + event: opened + binary: ./spinloop-bin/spinloop + url: http://127.0.0.1:4110 + token: e2e-token + id: ${{ steps.work_issue.outputs.number }} + issue-number: ${{ steps.work_issue.outputs.number }} + issue-title: e2e work item + issue-body: resolve this + dir: . + + - name: Assert the item appears once + run: | + set -euo pipefail + LIST="$(curl -fsS -H "Authorization: Bearer e2e-token" http://127.0.0.1:4110/v1/items)" + COUNT="$(grep -o "\"id\":\"${{ steps.work_issue.outputs.number }}\"" <<< "$LIST" | wc -l)" + [ "$COUNT" = 1 ] + + - name: Close the work issue + env: + GH_TOKEN: ${{ github.token }} + run: gh issue close "${{ steps.work_issue.outputs.number }}" + + - name: A closed issue removes the item + uses: ./work-items + with: + event: closed + binary: ./spinloop-bin/spinloop + url: http://127.0.0.1:4110 + token: e2e-token + id: ${{ steps.work_issue.outputs.number }} + issue-number: ${{ steps.work_issue.outputs.number }} + dir: . + + - name: Assert the item was removed + run: | + set -euo pipefail + LIST="$(curl -fsS -H "Authorization: Bearer e2e-token" http://127.0.0.1:4110/v1/items)" + if grep -q "\"id\":\"${{ steps.work_issue.outputs.number }}\"" <<< "$LIST"; then + echo "the item for the closed issue should be gone" >&2 + exit 1 + fi + + - name: A repeated close is a no-op + uses: ./work-items + with: + event: closed + binary: ./spinloop-bin/spinloop + url: http://127.0.0.1:4110 + token: e2e-token + id: ${{ steps.work_issue.outputs.number }} + issue-number: ${{ steps.work_issue.outputs.number }} + dir: . + + - name: Assert the item is still gone + run: | + set -euo pipefail + LIST="$(curl -fsS -H "Authorization: Bearer e2e-token" http://127.0.0.1:4110/v1/items)" + if grep -q "\"id\":\"${{ steps.work_issue.outputs.number }}\"" <<< "$LIST"; then + echo "the item for the closed issue should be gone" >&2 + exit 1 + fi + + - name: An issue without the wanted label is not work + uses: ./work-items + with: + event: opened + binary: ./spinloop-bin/spinloop + url: http://127.0.0.1:4110 + token: e2e-token + id: ${{ steps.not_work_issue.outputs.number }} + issue-number: ${{ steps.not_work_issue.outputs.number }} + issue-title: e2e not work + issue-body: leave this alone + issue-labels: "" + labels: orchestration + dir: . + + - name: Assert the not-work issue added nothing + run: | + set -euo pipefail + LIST="$(curl -fsS -H "Authorization: Bearer e2e-token" http://127.0.0.1:4110/v1/items)" + if grep -q "\"id\":\"${{ steps.not_work_issue.outputs.number }}\"" <<< "$LIST"; then + echo "the not-work issue should not have become work" >&2 + exit 1 + fi + + - name: Close both issues + if: always() + env: + GH_TOKEN: ${{ github.token }} + run: | + gh issue close "${{ steps.work_issue.outputs.number }}" || true + gh issue close "${{ steps.not_work_issue.outputs.number }}" || true + + - name: Stop the orchestrator and the stub gateway + if: always() + run: | + kill "$(cat orchestrator.pid)" 2>/dev/null || true + kill "$(cat stub-gateway.pid)" 2>/dev/null || true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..0e30256 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,23 @@ +name: Lint + +on: + push: + pull_request: + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Download actionlint + id: get_actionlint + shell: bash + run: bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + + # The composite action at work-items/ is checked through the + # uses: ./work-items reference in e2e.yml: actionlint reads its + # metadata and validates the with: inputs against it. + - name: Check the workflows + shell: bash + run: ${{ steps.get_actionlint.outputs.executable }} -shellcheck=none .github/workflows/*.yml diff --git a/README.md b/README.md index f5c3502..bca931b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ # spinloop GitHub Actions GitHub actions that work with [spinloop](https://github.com/spinloop-ai/spinloop). + +## Actions + +- [`work-items`](work-items/) — keep an orchestrator's work list in step with GitHub issues, over its work list API: an issue opened adds an item, a closed issue removes it + +## Testing + +`.github/workflows/lint.yml` lints the workflows and the action metadata on +every push. `.github/workflows/e2e.yml` is manual (`workflow_dispatch`): it +builds spinloop from the ref it is given, stands up a real orchestrator with a +stub gateway, and works real issues through the action against it. diff --git a/work-items/README.md b/work-items/README.md new file mode 100644 index 0000000..fb195d4 --- /dev/null +++ b/work-items/README.md @@ -0,0 +1,76 @@ +# work-items + +A composite GitHub action that keeps an orchestrator's work list in step with +GitHub issues, over the +[orchestrator's work list API](https://github.com/spinloop-ai/spinloop/blob/main/docs/commands/orchestrator.md#the-work-list-api). +An issue **opened** adds an item to the work list; an issue **closed** removes +it. The orchestrator runs wherever the `url` points — a server, a lab +machine, a box in a rack — and the action is only its client, so a backlog of +issues becomes the backlog the orchestrator works, with no operator in +between and no file to commit. + +The action carries its own `spinloop`: it downloads the release it works with +for the runner's platform, so a runner installs nothing. A spinloop release +that carries the API-based `work` command family is required — v1.40.0's +`work` commands still work the items file, so until a release carries the API +client, pin `version` to such a tag or pass a local build through `binary`, +and the action says so where the binary it resolved will not do the job. + +## Use it + +Add a workflow to the repo whose issues are the work: + +```yaml +name: work items + +on: + issues: + types: [opened, closed] + +jobs: + work-items: + runs-on: ubuntu-latest + steps: + - uses: spinloop-ai/github-actions/work-items@main + with: + url: http://your-orchestrator:4010 + token: ${{ secrets.ORCHESTRATOR_API_TOKEN }} + dir: /srv/work + # Only issues carrying one of these labels become work. Where none + # are named, every issue does. + # labels: orchestration +``` + +No checkout, no `contents` permission: the action writes nothing to the repo. +`dir` is where the item's agent works, on the machine the orchestrator runs — +the runner's path means nothing there. Pin +`spinloop-ai/github-actions/work-items` to a tag once the action has +releases, rather than `main`. + +## Inputs + +| Input | Default | Meaning | +| --- | --- | --- | +| `url` | — (required) | the work list API's base address — the one the orchestrator prints at its start | +| `token` | none | the API's bearer token; where the run serves loopback with no token, leave it empty, and `SPINLOOP_API_TOKEN` in the environment is the fallback where no flag is given | +| `event` | the event's action | `opened` adds an item, `closed` removes one; nothing else is worked | +| `id` | the issue's number | the item's id, overridable | +| `template` | the issue's title, then its body | the item's instructions, rendered against the issue with `{{.Title}}`, `{{.Body}}`, `{{.Number}}`, `{{.URL}}`, `{{.Labels}}` | +| `dir` | `.` | the directory the item's agent works in, on the machine the orchestrator runs | +| `tags` | none | the item's tags, comma-separated `key=value` pairs binding it to a kind of node | +| `priority` | `0` | the item's priority, higher first | +| `labels` | none | the labels an issue must carry, one of them, to become work; none named, every issue does | +| `version` | `latest` | the spinloop release the client downloads — `latest`, or a tag | +| `binary` | none | a spinloop binary to work with instead of downloading a release | +| `issue-title`, `issue-body`, `issue-number`, `issue-url`, `issue-labels` | the event's issue | the issue's fields; defaulted from the event, named for a workflow that works an issue the event does not carry | + +## What it does to the work list + +The action calls the `work` command family against the API: `spinloop work +add` where the event is `opened`, `spinloop work remove` where it is +`closed`. A re-run of the same event is a no-op: an id the work list already +carries is reported as already added, an id it no longer carries is reported +as already removed, and the run is untouched either way. A close whose item is +running is refused the way the API refuses it — naming the item and the abort +that goes first — and the refusal stands as the action's failure; the action +does not stop a live item on its own. diff --git a/work-items/action.yml b/work-items/action.yml new file mode 100644 index 0000000..473b6e7 --- /dev/null +++ b/work-items/action.yml @@ -0,0 +1,216 @@ +name: work-items +description: >- + Keep an orchestrator's work list in step with GitHub issues, over the + orchestrator's work list API: an issue opened adds an item, a closed issue + removes it — the orchestrator runs wherever the URL points. + +inputs: + url: + description: The orchestrator's work list API base address — the one the run prints at its start + required: true + token: + description: >- + The work list API's bearer token; where the run serves loopback with no + token, leave it empty — the SPINLOOP_API_TOKEN environment is the + fallback where the flag is not given + default: "" + event: + description: The issue event to work — opened (adds an item) or closed (removes one) + default: ${{ github.event.action }} + id: + description: The item's id; defaults to the issue's number + default: ${{ github.event.issue.number }} + template: + description: >- + The item's instructions, rendered against the issue with {{.Title}}, + {{.Body}}, {{.Number}}, {{.URL}} and {{.Labels}} + default: "{{.Title}}\n\n{{.Body}}" + dir: + description: The directory the item's agent works in, on the machine the orchestrator runs + default: . + tags: + description: The item's tags, comma-separated key=value pairs binding it to a kind of node + default: "" + priority: + description: The item's priority, higher first + default: "0" + labels: + description: >- + The labels an issue must carry, one of them, to become work, + comma-separated; where none are named, every issue does + default: "" + version: + description: The spinloop release to work with — latest, or a tag + default: latest + binary: + description: >- + A spinloop binary to work with instead of downloading a release; the + version input is ignored where it is set + default: "" + issue-title: + description: The issue's title; defaulted from the event, named for a workflow that works an issue the event does not carry + default: ${{ github.event.issue.title }} + issue-body: + description: The issue's body; defaulted from the event, named for a workflow that works an issue the event does not carry + default: ${{ github.event.issue.body }} + issue-number: + description: The issue's number; defaulted from the event, named for a workflow that works an issue the event does not carry + default: ${{ github.event.issue.number }} + issue-url: + description: The issue's URL; defaulted from the event, named for a workflow that works an issue the event does not carry + default: ${{ github.event.issue.html_url }} + issue-labels: + description: The issue's labels, comma-separated; defaulted from the event, named for a workflow that works an issue the event does not carry + default: ${{ join(github.event.issue.labels.*.name, ',') }} + +runs: + using: composite + steps: + - id: spinloop + name: Resolve spinloop + shell: bash + env: + BINARY: ${{ inputs.binary }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + if [ -n "$BINARY" ]; then + if [ ! -x "$BINARY" ]; then + echo "the binary input names $BINARY, which is not an executable file: point it at a built spinloop" >&2 + exit 1 + fi + BIN="$BINARY" + else + if [ "$VERSION" = "latest" ]; then + TAG="$(node -e 'const h={headers:{}};if(process.env.GITHUB_TOKEN)h.headers.authorization="Bearer "+process.env.GITHUB_TOKEN;fetch("https://api.github.com/repos/spinloop-ai/spinloop/releases/latest",h).then(r=>{if(!r.ok){console.error("looking up the latest spinloop release failed with HTTP "+r.status);process.exit(1)}return r.json()}).then(j=>console.log(j.tag_name))')" + else + TAG="$VERSION" + fi + case "$TAG" in v*) ;; *) TAG="v$TAG" ;; esac + + case "$RUNNER_OS" in + Linux) OS=linux ;; + macOS) OS=darwin ;; + Windows) OS=windows ;; + *) echo "the runner's operating system $RUNNER_OS has no spinloop release asset" >&2; exit 1 ;; + esac + case "$RUNNER_ARCH" in + X64) ARCH=amd64 ;; + ARM64) ARCH=arm64 ;; + *) echo "the runner's architecture $RUNNER_ARCH has no spinloop release asset: use an x64 or arm64 runner" >&2; exit 1 ;; + esac + case "$OS" in + windows) EXT=zip ;; + *) EXT=tar.gz ;; + esac + + ASSET="spinloop_${OS}_${ARCH}.${EXT}" + URL="https://github.com/spinloop-ai/spinloop/releases/download/${TAG}/${ASSET}" + echo "downloading spinloop $TAG ($ASSET)" + DIR="$(mktemp -d)" + curl -fSL "$URL" -o "$DIR/$ASSET" + tar -xf "$DIR/$ASSET" -C "$DIR" + BIN="$(find "$DIR" -type f \( -name spinloop -o -name spinloop.exe \) | head -n 1)" + if [ -z "$BIN" ]; then + echo "no spinloop binary in $ASSET for $TAG" >&2 + exit 1 + fi + chmod +x "$BIN" + fi + + if ! "$BIN" work add --help 2>&1 | grep -q -- "--url"; then + echo "the spinloop at $BIN has no API-based work command: its work commands still work the items file — pin version to a release that carries the API-based work command family, or pass a local build via binary" >&2 + exit 1 + fi + echo "bin=$BIN" >> "$GITHUB_OUTPUT" + + - id: work + name: Add or remove the item + shell: bash + env: + BIN: ${{ steps.spinloop.outputs.bin }} + EVENT: ${{ inputs.event }} + URL: ${{ inputs.url }} + TOKEN: ${{ inputs.token }} + ID: ${{ inputs.id }} + TEMPLATE: ${{ inputs.template }} + DIR: ${{ inputs.dir }} + TAGS: ${{ inputs.tags }} + PRIORITY: ${{ inputs.priority }} + LABELS: ${{ inputs.labels }} + ISSUE_TITLE: ${{ inputs.issue-title }} + ISSUE_BODY: ${{ inputs.issue-body }} + ISSUE_NUMBER: ${{ inputs.issue-number }} + ISSUE_URL: ${{ inputs.issue-url }} + ISSUE_LABELS: ${{ inputs.issue-labels }} + run: | + set -euo pipefail + + case "$EVENT" in + opened|closed) ;; + *) echo "event $EVENT is not one the action works: it works opened (adds an item) and closed (removes one)" >&2; exit 1 ;; + esac + + INDIR="$(mktemp)" + export INDIR + GATE="$(node "${{ github.action_path }}/lib/render.js")" + case "$GATE" in + SKIP:*) + echo "$GATE" + exit 0 + ;; + GO) ;; + *) echo "the render step said $GATE, which is neither GO nor SKIP" >&2; exit 1 ;; + esac + + INSTR="$(cat "$INDIR")" + + TAG_ARGS=() + if [ -n "$TAGS" ]; then + while IFS= read -r t || [ -n "$t" ]; do + t="$(printf '%s' "$t" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [ -n "$t" ]; then TAG_ARGS+=("--tag" "$t"); fi + done < <(printf '%s' "$TAGS" | tr ',' '\n') + fi + + API_ARGS=(--url "$URL") + if [ -n "$TOKEN" ]; then + API_ARGS+=(--api-token "$TOKEN") + fi + + case "$EVENT" in + opened) + ADD_ARGS=("${API_ARGS[@]}" --id "$ID" --instructions "$INSTR" --dir "$DIR") + if [ -n "$PRIORITY" ]; then ADD_ARGS+=(--priority "$PRIORITY"); fi + if [ ${#TAG_ARGS[@]} -gt 0 ]; then ADD_ARGS+=("${TAG_ARGS[@]}"); fi + set +e + OUT="$( "$BIN" work add "${ADD_ARGS[@]}" 2>&1 )"; RC=$? + set -e + if [ "$RC" -ne 0 ]; then + case "$OUT" in + *"already carries an item with id"*) + echo "item $ID is already in the work list: nothing to add, the run is untouched" + exit 0 + ;; + *) printf '%s\n' "$OUT" >&2; exit "$RC" ;; + esac + fi + printf '%s\n' "$OUT" + ;; + closed) + set +e + OUT="$( "$BIN" work remove "$ID" "${API_ARGS[@]}" 2>&1 )"; RC=$? + set -e + if [ "$RC" -ne 0 ]; then + case "$OUT" in + *"carries no item with id"*) + echo "item $ID is not in the work list: nothing to remove, the run is untouched" + exit 0 + ;; + *) printf '%s\n' "$OUT" >&2; exit "$RC" ;; + esac + fi + printf '%s\n' "$OUT" + ;; + esac diff --git a/work-items/lib/render.js b/work-items/lib/render.js new file mode 100644 index 0000000..eb7a493 --- /dev/null +++ b/work-items/lib/render.js @@ -0,0 +1,38 @@ +// The label gate and the template render for the work-items action. It reads +// the action's inputs from the environment, decides whether the issue is work +// at all, and where it is, renders the item's instructions and writes them to +// the file named by INDIR. It prints one line to stdout: "SKIP: " +// where the issue is not work, or "GO" where it is. +"use strict"; + +const fs = require("fs"); + +const split = (s) => s.split(",").map((x) => x.trim()).filter((x) => x !== ""); + +const wanted = split(process.env.LABELS || ""); +const carried = split(process.env.ISSUE_LABELS || ""); +const number = process.env.ISSUE_NUMBER || ""; + +if (wanted.length > 0 && !wanted.some((w) => carried.includes(w))) { + console.log( + `SKIP: issue #${number} carries none of the labels this action works (` + + `${wanted.join(", ")}): it is not work, and the work list is untouched`, + ); + process.exit(0); +} + +const fields = { + Title: process.env.ISSUE_TITLE || "", + Body: process.env.ISSUE_BODY || "", + Number: number, + URL: process.env.ISSUE_URL || "", + Labels: (process.env.ISSUE_LABELS || "").replace(/,/g, ", "), +}; + +let out = process.env.TEMPLATE || ""; +out = out.replace(/\{\{\s*\.(\w+)\s*\}\}/g, (match, key) => + key in fields ? fields[key] : match, +); + +fs.writeFileSync(process.env.INDIR, out); +console.log("GO");