From ce18b9d6a9b39b30eb4d67fe55de69c8e0c59bba Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 14 Sep 2026 23:40:54 +0100 Subject: [PATCH 1/4] feat: add the work-items action A composite GitHub action that turns issue events into orchestrator work items: an issue opened adds an item to the work items file, a closed issue removes it. The action carries its own spinloop, works the file in the caller's repo, and commits and pushes the result with the workflow's token. The label gate and template render live in a small node helper; a re-run of an event is a no-op, and a close whose item is running is refused the way work remove refuses it. Adds a copy-paste workflow to the README, an actionlint workflow, and a manual end-to-end workflow. --- .github/workflows/e2e.yml | 159 +++++++++++++++++++++++++ .github/workflows/lint.yml | 23 ++++ README.md | 4 + work-items/README.md | 88 ++++++++++++++ work-items/action.yml | 238 +++++++++++++++++++++++++++++++++++++ work-items/lib/render.js | 38 ++++++ 6 files changed, 550 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 .github/workflows/lint.yml create mode 100644 work-items/README.md create mode 100644 work-items/action.yml create mode 100644 work-items/lib/render.js diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..931db80 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,159 @@ +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: Create the work issue + id: work_issue + env: + GH_TOKEN: ${{ github.token }} + run: | + number="$(gh issue create --title 'e2e work item' --body 'resolve this' --json number --jq .number)" + echo "number=${number}" >> "$GITHUB_OUTPUT" + + - name: Create the not-work issue + id: not_work_issue + env: + GH_TOKEN: ${{ github.token }} + run: | + number="$(gh issue create --title 'e2e not work' --body 'leave this alone' --json number --jq .number)" + echo "number=${number}" >> "$GITHUB_OUTPUT" + + - name: An opened issue adds an item + uses: ./work-items + with: + event: opened + binary: ./spinloop-bin/spinloop + items: work.yaml + id: ${{ steps.work_issue.outputs.number }} + issue-number: ${{ steps.work_issue.outputs.number }} + issue-title: e2e work item + issue-body: resolve this + dir: . + push: "false" + + - name: Assert the item was added + run: | + test -f work.yaml + grep -q "${{ steps.work_issue.outputs.number }}" work.yaml + + - name: A repeated open is a no-op + uses: ./work-items + with: + event: opened + binary: ./spinloop-bin/spinloop + items: work.yaml + id: ${{ steps.work_issue.outputs.number }} + issue-number: ${{ steps.work_issue.outputs.number }} + issue-title: e2e work item + issue-body: resolve this + dir: . + push: "false" + + - name: Assert the item appears once + run: test "$(grep -c "${{ steps.work_issue.outputs.number }}" work.yaml)" = 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 + items: work.yaml + id: ${{ steps.work_issue.outputs.number }} + issue-number: ${{ steps.work_issue.outputs.number }} + dir: . + push: "false" + + - name: Assert the item was removed + run: | + if grep -q "${{ steps.work_issue.outputs.number }}" work.yaml 2>/dev/null; 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 + items: work.yaml + id: ${{ steps.work_issue.outputs.number }} + issue-number: ${{ steps.work_issue.outputs.number }} + dir: . + push: "false" + + - name: Assert the item is still gone + run: | + if grep -q "${{ steps.work_issue.outputs.number }}" work.yaml 2>/dev/null; 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 + items: work.yaml + 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: . + push: "false" + + - name: Assert the not-work issue added nothing + run: | + if grep -q "${{ steps.not_work_issue.outputs.number }}" work.yaml 2>/dev/null; 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 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..8465b6a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # spinloop GitHub Actions GitHub actions that work with [spinloop](https://github.com/spinloop-ai/spinloop). + +## Actions + +- [`work-items`](work-items/) — turn GitHub issue events into orchestrator work items: an issue opened adds an item to the work items file, a closed issue removes it diff --git a/work-items/README.md b/work-items/README.md new file mode 100644 index 0000000..453cdb9 --- /dev/null +++ b/work-items/README.md @@ -0,0 +1,88 @@ +# work-items + +A composite GitHub action that turns issue events into orchestrator work items. +An issue **opened** adds an item to the work items file; an issue **closed** +removes it. The items file is the backlog the +[orchestrator](https://github.com/spinloop-ai/spinloop) works, so a backlog of +issues becomes the backlog the orchestrator picks up, with no operator in +between. The action works the file in the caller's repo and, where `push` is +on, commits and pushes the result with the workflow's token. + +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 `work` command family is required. + +## Use it + +Add a workflow to the repo that holds your work items file: + +```yaml +name: work items + +on: + issues: + types: [opened, closed] + +permissions: + contents: write + +jobs: + work-items: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: spinloop-ai/github-actions/work-items@main + with: + items: work.yaml + dir: . + # Only issues carrying one of these labels become work. Where none + # are named, every issue does. + # labels: orchestration +``` + +`contents: write` is what the push needs; drop it and set `push: "false"` +where you commit the file yourself. The action pushes to the branch the +workflow runs on. + +Pin `spinloop-ai/github-actions/work-items` to a tag once the action has +releases, rather than `main`. + +The orchestrator keeps its state, lock, logs, and abort markers beside the +items file. Those are machine-local and must not be committed; where your +items file is `work.yaml`, gitignore: + +```gitignore +work.yaml.state.json +work.yaml.lock +work.yaml.logs/ +work.yaml.aborts/ +``` + +## Inputs + +| Input | Default | Meaning | +| --- | --- | --- | +| `event` | the event's action | `opened` adds an item, `closed` removes one; nothing else is worked | +| `items` | `work.yaml` | the work items file, relative to the repo root | +| `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 | +| `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 to work with, or a tag such as `v1.40.0` | +| `binary` | none | a spinloop binary to work with instead of downloading a release | +| `push` | `true` | commit and push the worked file with the workflow's token | +| `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 file + +The action calls the `work` command family: `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 file already carries is reported as +already added, an id the file has let go is reported as already removed, and +the file is untouched either way. A close whose item is running is refused the +way `work remove` 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..ad3f326 --- /dev/null +++ b/work-items/action.yml @@ -0,0 +1,238 @@ +name: work-items +description: >- + Turn GitHub issue events into orchestrator work items: an issue opened adds + an item to the work items file, a closed issue removes it — the file the + orchestrator works, worked in the caller's repo and, where it is on, + committed and pushed with the workflow's token. + +inputs: + event: + description: The issue event to work — opened (adds an item) or closed (removes one) + default: ${{ github.event.action }} + items: + description: The work items file, relative to the repo root + default: work.yaml + 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 such as v1.40.0 + 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: "" + push: + description: "Commit and push the worked items file with the workflow's token — true, or false to leave committing to the caller" + default: "true" + 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 --help >/dev/null 2>&1; then + echo "the spinloop at $BIN has no work command: pin version to a release that carries the work command family" >&2 + exit 1 + fi + echo "bin=$BIN" >> "$GITHUB_OUTPUT" + + - id: work + name: Work the items file + shell: bash + env: + BIN: ${{ steps.spinloop.outputs.bin }} + EVENT: ${{ inputs.event }} + ITEMS: ${{ inputs.items }} + 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 + + MSG="" + case "$EVENT" in + opened) + ADD_ARGS=(work add --items "$ITEMS" --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" "${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 $ITEMS: nothing to add, the file is untouched" + exit 0 + ;; + *) printf '%s\n' "$OUT" >&2; exit "$RC" ;; + esac + fi + printf '%s\n' "$OUT" + MSG="add item $ID (issue #$ISSUE_NUMBER)" + ;; + closed) + set +e + OUT="$( "$BIN" work remove "$ID" --items "$ITEMS" 2>&1 )"; RC=$? + set -e + if [ "$RC" -ne 0 ]; then + case "$OUT" in + *"carries no item with id"*) + echo "item $ID is not in $ITEMS: nothing to remove, the file is untouched" + exit 0 + ;; + *) printf '%s\n' "$OUT" >&2; exit "$RC" ;; + esac + fi + printf '%s\n' "$OUT" + MSG="remove item $ID (issue #$ISSUE_NUMBER)" + ;; + esac + echo "msg=$MSG" >> "$GITHUB_OUTPUT" + + - id: commit + name: Commit and push + shell: bash + env: + ITEMS: ${{ inputs.items }} + PUSH: ${{ inputs.push }} + MSG: ${{ steps.work.outputs.msg }} + run: | + set -euo pipefail + + if [ "$PUSH" != "true" ]; then + echo "push is off: the items file is worked, but committing and pushing is left to the caller" + exit 0 + fi + + if git status --porcelain -- "$ITEMS" | grep -q .; then + git add -- "$ITEMS" + git -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -m "$MSG" + git push origin "HEAD:$GITHUB_REF_NAME" + echo "committed and pushed the updated $ITEMS" + else + echo "the items file is unchanged: nothing to commit" + fi diff --git a/work-items/lib/render.js b/work-items/lib/render.js new file mode 100644 index 0000000..886bbe5 --- /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 items file 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"); From ec802bcce5535f83fe0c8d0bcf2837aab968c216 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 14 Sep 2026 23:50:18 +0100 Subject: [PATCH 2/4] ci: extract the e2e issue number from its URL gh issue create does not support --json/--jq; it prints the issue's URL. Read the number off the end of it. --- .github/workflows/e2e.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 931db80..991e733 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -42,16 +42,16 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - number="$(gh issue create --title 'e2e work item' --body 'resolve this' --json number --jq .number)" - echo "number=${number}" >> "$GITHUB_OUTPUT" + 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: | - number="$(gh issue create --title 'e2e not work' --body 'leave this alone' --json number --jq .number)" - echo "number=${number}" >> "$GITHUB_OUTPUT" + 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 From 4ca3893404e41d7c6db6ea146a747aaffc48d3fa Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Tue, 15 Sep 2026 01:26:26 +0100 Subject: [PATCH 3/4] feat: add and remove items through the orchestrator's API --- .github/workflows/e2e.yml | 112 ++++++++++++++++++++++++++++++++------ README.md | 9 ++- work-items/README.md | 75 ++++++++++--------------- work-items/action.yml | 72 +++++++++--------------- work-items/lib/render.js | 2 +- 5 files changed, 160 insertions(+), 110 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 991e733..4f9059b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -37,6 +37,67 @@ jobs: 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: @@ -58,34 +119,41 @@ jobs: with: event: opened binary: ./spinloop-bin/spinloop - items: work.yaml + 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: . - push: "false" - name: Assert the item was added run: | - test -f work.yaml - grep -q "${{ steps.work_issue.outputs.number }}" work.yaml + 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 - items: work.yaml + 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: . - push: "false" - name: Assert the item appears once - run: test "$(grep -c "${{ steps.work_issue.outputs.number }}" work.yaml)" = 1 + 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: @@ -97,15 +165,17 @@ jobs: with: event: closed binary: ./spinloop-bin/spinloop - items: work.yaml + url: http://127.0.0.1:4110 + token: e2e-token id: ${{ steps.work_issue.outputs.number }} issue-number: ${{ steps.work_issue.outputs.number }} dir: . - push: "false" - name: Assert the item was removed run: | - if grep -q "${{ steps.work_issue.outputs.number }}" work.yaml 2>/dev/null; then + 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 @@ -115,15 +185,17 @@ jobs: with: event: closed binary: ./spinloop-bin/spinloop - items: work.yaml + url: http://127.0.0.1:4110 + token: e2e-token id: ${{ steps.work_issue.outputs.number }} issue-number: ${{ steps.work_issue.outputs.number }} dir: . - push: "false" - name: Assert the item is still gone run: | - if grep -q "${{ steps.work_issue.outputs.number }}" work.yaml 2>/dev/null; then + 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 @@ -133,7 +205,8 @@ jobs: with: event: opened binary: ./spinloop-bin/spinloop - items: work.yaml + 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 @@ -141,11 +214,12 @@ jobs: issue-labels: "" labels: orchestration dir: . - push: "false" - name: Assert the not-work issue added nothing run: | - if grep -q "${{ steps.not_work_issue.outputs.number }}" work.yaml 2>/dev/null; then + 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 @@ -157,3 +231,9 @@ jobs: 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/README.md b/README.md index 8465b6a..bca931b 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,11 @@ GitHub actions that work with [spinloop](https://github.com/spinloop-ai/spinloop ## Actions -- [`work-items`](work-items/) — turn GitHub issue events into orchestrator work items: an issue opened adds an item to the work items file, a closed issue removes it +- [`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 index 453cdb9..1a294d6 100644 --- a/work-items/README.md +++ b/work-items/README.md @@ -1,20 +1,21 @@ # work-items -A composite GitHub action that turns issue events into orchestrator work items. -An issue **opened** adds an item to the work items file; an issue **closed** -removes it. The items file is the backlog the -[orchestrator](https://github.com/spinloop-ai/spinloop) works, so a backlog of -issues becomes the backlog the orchestrator picks up, with no operator in -between. The action works the file in the caller's repo and, where `push` is -on, commits and pushes the result with the workflow's token. +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 `work` command family is required. +that carries the `work` command family is required (v1.40.0 and later). ## Use it -Add a workflow to the repo that holds your work items file: +Add a workflow to the repo whose issues are the work: ```yaml name: work items @@ -23,66 +24,50 @@ on: issues: types: [opened, closed] -permissions: - contents: write - jobs: work-items: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: spinloop-ai/github-actions/work-items@main with: - items: work.yaml - dir: . + 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 ``` -`contents: write` is what the push needs; drop it and set `push: "false"` -where you commit the file yourself. The action pushes to the branch the -workflow runs on. - -Pin `spinloop-ai/github-actions/work-items` to a tag once the action has +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`. -The orchestrator keeps its state, lock, logs, and abort markers beside the -items file. Those are machine-local and must not be committed; where your -items file is `work.yaml`, gitignore: - -```gitignore -work.yaml.state.json -work.yaml.lock -work.yaml.logs/ -work.yaml.aborts/ -``` - ## 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 | -| `items` | `work.yaml` | the work items file, relative to the repo root | | `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 | +| `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 to work with, or a tag such as `v1.40.0` | +| `version` | `latest` | the spinloop release the client downloads, or a tag such as `v1.40.0` | | `binary` | none | a spinloop binary to work with instead of downloading a release | -| `push` | `true` | commit and push the worked file with the workflow's token | | `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 file +## What it does to the work list -The action calls the `work` command family: `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 file already carries is reported as -already added, an id the file has let go is reported as already removed, and -the file is untouched either way. A close whose item is running is refused the -way `work remove` 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. +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 index ad3f326..87fabca 100644 --- a/work-items/action.yml +++ b/work-items/action.yml @@ -1,17 +1,22 @@ name: work-items description: >- - Turn GitHub issue events into orchestrator work items: an issue opened adds - an item to the work items file, a closed issue removes it — the file the - orchestrator works, worked in the caller's repo and, where it is on, - committed and pushed with the workflow's token. + 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 }} - items: - description: The work items file, relative to the repo root - default: work.yaml id: description: The item's id; defaults to the issue's number default: ${{ github.event.issue.number }} @@ -42,9 +47,6 @@ inputs: A spinloop binary to work with instead of downloading a release; the version input is ignored where it is set default: "" - push: - description: "Commit and push the worked items file with the workflow's token — true, or false to leave committing to the caller" - default: "true" 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 }} @@ -124,12 +126,13 @@ runs: echo "bin=$BIN" >> "$GITHUB_OUTPUT" - id: work - name: Work the items file + name: Add or remove the item shell: bash env: BIN: ${{ steps.spinloop.outputs.bin }} EVENT: ${{ inputs.event }} - ITEMS: ${{ inputs.items }} + URL: ${{ inputs.url }} + TOKEN: ${{ inputs.token }} ID: ${{ inputs.id }} TEMPLATE: ${{ inputs.template }} DIR: ${{ inputs.dir }} @@ -171,68 +174,43 @@ runs: done < <(printf '%s' "$TAGS" | tr ',' '\n') fi - MSG="" + API_ARGS=(--url "$URL") + if [ -n "$TOKEN" ]; then + API_ARGS+=(--api-token "$TOKEN") + fi + case "$EVENT" in opened) - ADD_ARGS=(work add --items "$ITEMS" --id "$ID" --instructions "$INSTR" --dir "$DIR") + 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" "${ADD_ARGS[@]}" 2>&1 )"; RC=$? + 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 $ITEMS: nothing to add, the file is untouched" + 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" - MSG="add item $ID (issue #$ISSUE_NUMBER)" ;; closed) set +e - OUT="$( "$BIN" work remove "$ID" --items "$ITEMS" 2>&1 )"; RC=$? + 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 $ITEMS: nothing to remove, the file is untouched" + 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" - MSG="remove item $ID (issue #$ISSUE_NUMBER)" ;; esac - echo "msg=$MSG" >> "$GITHUB_OUTPUT" - - - id: commit - name: Commit and push - shell: bash - env: - ITEMS: ${{ inputs.items }} - PUSH: ${{ inputs.push }} - MSG: ${{ steps.work.outputs.msg }} - run: | - set -euo pipefail - - if [ "$PUSH" != "true" ]; then - echo "push is off: the items file is worked, but committing and pushing is left to the caller" - exit 0 - fi - - if git status --porcelain -- "$ITEMS" | grep -q .; then - git add -- "$ITEMS" - git -c user.name="github-actions[bot]" \ - -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ - commit -m "$MSG" - git push origin "HEAD:$GITHUB_REF_NAME" - echo "committed and pushed the updated $ITEMS" - else - echo "the items file is unchanged: nothing to commit" - fi diff --git a/work-items/lib/render.js b/work-items/lib/render.js index 886bbe5..eb7a493 100644 --- a/work-items/lib/render.js +++ b/work-items/lib/render.js @@ -16,7 +16,7 @@ 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 items file is untouched`, + `${wanted.join(", ")}): it is not work, and the work list is untouched`, ); process.exit(0); } From 90431954ca1cb45b71813789f88c8acb62184c03 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Tue, 15 Sep 2026 01:29:46 +0100 Subject: [PATCH 4/4] fix: refuse a spinloop whose work command still works the file --- work-items/README.md | 7 +++++-- work-items/action.yml | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/work-items/README.md b/work-items/README.md index 1a294d6..fb195d4 100644 --- a/work-items/README.md +++ b/work-items/README.md @@ -11,7 +11,10 @@ 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 `work` command family is required (v1.40.0 and later). +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 @@ -57,7 +60,7 @@ releases, rather than `main`. | `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, or a tag such as `v1.40.0` | +| `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 | diff --git a/work-items/action.yml b/work-items/action.yml index 87fabca..473b6e7 100644 --- a/work-items/action.yml +++ b/work-items/action.yml @@ -40,7 +40,7 @@ inputs: comma-separated; where none are named, every issue does default: "" version: - description: The spinloop release to work with — latest, or a tag such as v1.40.0 + description: The spinloop release to work with — latest, or a tag default: latest binary: description: >- @@ -119,8 +119,8 @@ runs: chmod +x "$BIN" fi - if ! "$BIN" work --help >/dev/null 2>&1; then - echo "the spinloop at $BIN has no work command: pin version to a release that carries the work command family" >&2 + 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"