Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Sync this repo to Diffinery, with an audited push-protection bypass and quarantined pushes (#13)

* Sync this repository to Diffinery on every push and check its Actions there

The move workflow becomes a sync: each push copies branches, tags, secrets and
variables to diffinery.com/codician-team/diffinery (creating it the first time)
and, for main, waits for the Actions runs that start there and fails with their
logs if any fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL

* Mark the e2e test's fake AWS key so the test source passes push protection

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL

* Push protection: quarantine pushed objects and add an audited admin bypass

Objects from a pushed pack are only stored once push protection passes, so a
blocked push no longer leaves objects that a retry (with push negotiation)
could reuse to slip the secret past the scanner. Repository admins who are
people, never agents, can push anyway with git push -o allow-secrets=<reason>,
which is recorded in the audit log. The sync workflow can pass such a reason
on a manual run, for importing history with fake test keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 014d046commit cb1fcca5ce1947598517b6255b345c6a7fa1f93aBrowse files
8 files changed+225−110
Files (8)
deleted.github/workflows/move-to-diffinery.yml+0−95
@@ -1,95 +1,0 @@
1-name: Move to Diffinery
2-
3-# Copies this repository to diffinery.com/codician-team/diffinery: every branch
4-# and tag, plus the Actions secrets and variables our workflows use. Safe to
5-# re-run; each run brings Diffinery up to date.
6-#
7-# Secrets go straight from GitHub's secret store to Diffinery's over HTTPS and are
8-# never printed. Needs DIFFINERY_LIVE_TOKEN (an admin token on diffinery.com).
9-on:
10- workflow_dispatch:
11-
12-permissions:
13- contents: read
14-
15-concurrency:
16- group: move-to-diffinery
17- cancel-in-progress: false
18-
19-jobs:
20- move:
21- name: Copy code, secrets and variables
22- runs-on: ubuntu-latest
23- timeout-minutes: 20
24- environment: production
25- env:
26- DIFFINERY: https://diffinery.com
27- TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
28- ORG: codician-team
29- NAME: diffinery
30- steps:
31- - name: Check the token
32- run: |
33- [ -n "$TOKEN" ] || { echo "::error::Add DIFFINERY_LIVE_TOKEN (an admin token from $DIFFINERY/settings/tokens) as a repository secret first."; exit 1; }
34- curl -fsS "$DIFFINERY/api/v3/user" -H "Authorization: Bearer $TOKEN" | jq '{login, site_admin}'
35-
36- - name: Create the organization and repository
37- env:
38- DESC: ${{ github.event.repository.description }}
39- run: |
40- set -euo pipefail
41- api() { curl -sS -o /tmp/out -w '%{http_code}' -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; }
42- if [ "$(api GET "/users/$ORG")" = 404 ]; then
43- code=$(api POST /user/orgs "{\"login\":\"$ORG\",\"name\":\"Codician\"}"); cat /tmp/out; echo
44- [ "$code" = 201 ] || { echo "::error::could not create the $ORG organization ($code)"; exit 1; }
45- fi
46- if [ "$(api GET "/repos/$ORG/$NAME")" = 404 ]; then
47- desc=$(jq -n --arg d "$DESC" '$d')
48- code=$(api POST "/orgs/$ORG/repos" "{\"name\":\"$NAME\",\"private\":true,\"default_branch\":\"main\",\"description\":$desc}"); cat /tmp/out; echo
49- [ "$code" = 201 ] || { echo "::error::could not create $ORG/$NAME ($code)"; exit 1; }
50- fi
51- echo "$DIFFINERY/$ORG/$NAME is ready"
52-
53- - name: Copy Actions secrets and variables
54- env:
55- S_CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
56- S_CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
57- S_DIFFINERY_LIVE_TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
58- V_ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }}
59- run: |
60- set -euo pipefail
61- put() { curl -fsS -o /dev/null -X PUT "$DIFFINERY/api/v3/repos/$ORG/$NAME/actions/$1/$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @-; }
62- for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID DIFFINERY_LIVE_TOKEN; do
63- v="S_$name"
64- if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put secrets "$name"; echo "secret $name copied"; else echo "::warning::secret $name is empty here, so it was not copied"; fi
65- done
66- for name in ACCESS_TEAM_DOMAIN; do
67- v="V_$name"
68- if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put variables "$name"; echo "variable $name = ${!v}"; fi
69- done
70-
71- - uses: actions/checkout@v4
72- with:
73- fetch-depth: 0
74- lfs: true
75-
76- - name: Push every branch and tag
77- run: |
78- set -euo pipefail
79- git fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*'
80- remote="https://x-token:${TOKEN}@${DIFFINERY#https://}/$ORG/$NAME.git"
81- refs=$(git for-each-ref --format='+%(refname):refs/heads/%(refname:lstrip=3)' refs/remotes/origin | grep -v ':refs/heads/HEAD$')
82- git push -q "$remote" $refs '+refs/tags/*:refs/tags/*'
83- if git lfs ls-files | grep -q .; then git lfs push --all "$remote"; fi
84- echo "branches on Diffinery:"
85- curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | " \(.name) \(.commit.sha)"'
86- want=$(git rev-parse refs/remotes/origin/main)
87- got=$(curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.name=="main") | .commit.sha')
88- [ "$want" = "$got" ] || { echo "::error::main on Diffinery is $got, expected $want"; exit 1; }
89- echo "main matches GitHub at $want"
90-
91- - name: Check Actions on Diffinery
92- run: |
93- set -euo pipefail
94- curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/actions/workflows" -H "Authorization: Bearer $TOKEN" | jq -r '.workflows[] | "\(.path): \(.state) \(.errors | join("; "))"'
95- curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/actions/secrets" -H "Authorization: Bearer $TOKEN" | jq -r '.secrets[] | "secret \(.name)"'
added.github/workflows/sync-to-diffinery.yml+103−0View file
@@ -1,0 +1,103 @@
1+name: Sync to Diffinery
2+
3+# Keeps diffinery.com/codician-team/diffinery in step with this repository: every
4+# branch and tag, plus the Actions secrets and variables our workflows use. On the
5+# first run it creates the organization and repository. For main it then waits for
6+# the Actions runs on Diffinery and fails if any of them fail.
7+#
8+# Secrets go straight from GitHub's secret store to Diffinery's over HTTPS and are
9+# never printed. Needs DIFFINERY_LIVE_TOKEN (an admin token on diffinery.com).
10+on:
11+ push:
12+ workflow_dispatch:
13+ inputs:
14+ allow_secrets:
15+ description: Reason for pushing past Diffinery's push protection (admins only, audited). Leave empty normally.
16+ type: string
17+ default: ""
18+
19+permissions:
20+ contents: read
21+
22+concurrency:
23+ group: sync-to-diffinery
24+ cancel-in-progress: false
25+
26+jobs:
27+ move:
28+ name: Copy code, secrets and variables, then check Actions on Diffinery
29+ runs-on: ubuntu-latest
30+ timeout-minutes: 90
31+ environment: production
32+ env:
33+ DIFFINERY: https://diffinery.com
34+ TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
35+ ORG: codician-team
36+ NAME: diffinery
37+ steps:
38+ - name: Check the token
39+ run: |
40+ [ -n "$TOKEN" ] || { echo "::error::Add DIFFINERY_LIVE_TOKEN (an admin token from $DIFFINERY/settings/tokens) as a repository secret first."; exit 1; }
41+ curl -fsS "$DIFFINERY/api/v3/user" -H "Authorization: Bearer $TOKEN" | jq '{login, site_admin}'
42+
43+ - name: Create the organization and repository
44+ env:
45+ DESC: ${{ github.event.repository.description }}
46+ run: |
47+ set -euo pipefail
48+ api() { curl -sS -o /tmp/out -w '%{http_code}' -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; }
49+ if [ "$(api GET "/users/$ORG")" = 404 ]; then
50+ code=$(api POST /user/orgs "{\"login\":\"$ORG\",\"name\":\"Codician\"}"); cat /tmp/out; echo
51+ [ "$code" = 201 ] || { echo "::error::could not create the $ORG organization ($code)"; exit 1; }
52+ fi
53+ if [ "$(api GET "/repos/$ORG/$NAME")" = 404 ]; then
54+ desc=$(jq -n --arg d "$DESC" '$d')
55+ code=$(api POST "/orgs/$ORG/repos" "{\"name\":\"$NAME\",\"private\":true,\"default_branch\":\"main\",\"description\":$desc}"); cat /tmp/out; echo
56+ [ "$code" = 201 ] || { echo "::error::could not create $ORG/$NAME ($code)"; exit 1; }
57+ fi
58+ echo "$DIFFINERY/$ORG/$NAME is ready"
59+
60+ - name: Copy Actions secrets and variables
61+ env:
62+ S_CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
63+ S_CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
64+ S_DIFFINERY_LIVE_TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }}
65+ V_ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }}
66+ run: |
67+ set -euo pipefail
68+ put() { curl -fsS -o /dev/null -X PUT "$DIFFINERY/api/v3/repos/$ORG/$NAME/actions/$1/$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @-; }
69+ for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID DIFFINERY_LIVE_TOKEN; do
70+ v="S_$name"
71+ if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put secrets "$name"; echo "secret $name copied"; else echo "::warning::secret $name is empty here, so it was not copied"; fi
72+ done
73+ for name in ACCESS_TEAM_DOMAIN; do
74+ v="V_$name"
75+ if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put variables "$name"; echo "variable $name = ${!v}"; fi
76+ done
77+
78+ - uses: actions/checkout@v4
79+ with:
80+ fetch-depth: 0
81+ lfs: true
82+
83+ - name: Push every branch and tag
84+ env:
85+ ALLOW_SECRETS: ${{ inputs.allow_secrets }}
86+ run: |
87+ set -euo pipefail
88+ git fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*'
89+ remote="https://x-token:${TOKEN}@${DIFFINERY#https://}/$ORG/$NAME.git"
90+ refs=$(git for-each-ref --format='+%(refname):refs/heads/%(refname:lstrip=3)' refs/remotes/origin | grep -v ':refs/heads/HEAD$')
91+ git push -q ${ALLOW_SECRETS:+-o "allow-secrets=$ALLOW_SECRETS"} "$remote" $refs '+refs/tags/*:refs/tags/*'
92+ if git lfs ls-files | grep -q .; then git lfs push --all "$remote"; fi
93+ echo "branches on Diffinery:"
94+ curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | " \(.name) \(.commit.sha)"'
95+ want=$(git rev-parse refs/remotes/origin/main)
96+ got=$(curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.name=="main") | .commit.sha')
97+ [ "$want" = "$got" ] || { echo "::error::main on Diffinery is $got, expected $want"; exit 1; }
98+ echo "main matches GitHub at $want"
99+
100+ - name: Wait for Actions on Diffinery
101+ # Pushes to main start CI (and, once Diffinery deploys, the deploy) there.
102+ if: github.ref == 'refs/heads/main'
103+ run: scripts/diffinery-wait-runs.sh "$ORG/$NAME" "$(git rev-parse refs/remotes/origin/main)" 75
modifiedREADME.md+5−2View file
@@ -15,8 +15,11 @@
1515 (`/mcp`) or the REST API, and can start sandboxes to build and test.
1616 - **Secure by default.** People sign in with passkeys, with no passwords to
1717 leak or phish. Invites, roles and branch protection decide what they can do. Pushes are scanned for
18- leaked credentials. Secrets use envelope encryption, and every sensitive
19- action goes to an append-only audit log.
18+ leaked credentials and blocked if they contain any; nothing from a blocked
19+ push is kept. A repository admin (a person, never an agent) can push anyway
20+ with `git push -o allow-secrets=<reason>`, which is audited. A file can opt
21+ out with a `diffinery:allow-secret` comment, for test fixtures. Secrets use
22+ envelope encryption, and every sensitive action goes to an append-only audit log.
2023
2124 ## How it fits on Cloudflare
2225
modifiedapps/diffinery/src/git/secret-scan.ts+9−0View file
@@ -28,3 +28,12 @@
2828 }
2929 return hits;
3030 }
31+
32+/** The reason given with `git push -o allow-secrets=<reason>`, if any. */
33+export function secretBypassReason(pushOptions: string[]): string | null {
34+ for (const o of pushOptions) {
35+ const m = /^allow-secrets=(.+)$/.exec(o.trim());
36+ if (m && m[1]!.trim()) return m[1]!.trim().slice(0, 200);
37+ }
38+ return null;
39+}
modifiedapps/diffinery/src/repo/RepoDO.ts+31−13View file
@@ -35,7 +35,7 @@
3535 type RefEntry,
3636 type RefResult,
3737 } from "../git/protocol.js";
38-import { scanForSecrets } from "../git/secret-scan.js";
38+import { scanForSecrets, secretBypassReason } from "../git/secret-scan.js";
3939 import { HttpError, isValidRefName, now } from "../lib/util.js";
4040 import { Actions } from "./actions.js";
4141 import { Collab } from "./collab.js";
@@ -209,18 +209,17 @@
209209 const req = parseReceivePack(body);
210210 const messages: string[] = [];
211211 let unpackError: string | null = null;
212- const newBlobs: Array<{ oid: string; content: Uint8Array }> = [];
212+ // Like git's quarantine: nothing from the pack is stored until push protection passes,
213+ // so a blocked push leaves no objects behind for a retry to build on.
214+ let objects: Awaited<ReturnType<typeof parsePack>> = [];
213215 if (req.pack && req.pack.length > 0) {
214216 try {
215- const objects = await parsePack(req.pack, (oid) => this.store.read(oid));
216- for (const o of objects) {
217- await this.store.writeKnown(o.oid, o.type, o.content);
218- if (o.type === "blob" && o.content.length < 1024 * 1024) newBlobs.push({ oid: o.oid, content: o.content });
219- }
217+ objects = await parsePack(req.pack, (oid) => this.store.read(oid));
220218 } catch (e) {
221219 unpackError = (e as Error).message;
222220 }
223221 }
222+ const newBlobs = objects.filter((o) => o.type === "blob" && o.content.length < 1024 * 1024).map((o) => ({ oid: o.oid, content: o.content }));
224223 const results: RefResult[] = [];
225224 if (unpackError) {
226225 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" });
@@ -229,14 +228,33 @@
229228
230229 const leaks = scanForSecrets(newBlobs);
231230 if (leaks.length) {
232- messages.push("Push blocked: possible secrets detected.");
233- for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
234- messages.push("Remove the secret from your commits, or rotate it and contact an admin.");
235- for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" });
236- this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target: `${this.meta.owner}/${this.meta.name}`, detail: { kinds: leaks.map((l) => l.kind) }, at: now() });
237- return reportStatus(req, null, results, messages);
231+ const kinds = leaks.map((l) => l.kind);
232+ const target = `${this.meta.owner}/${this.meta.name}`;
233+ // Like GitHub's push protection bypass: a human repository admin may push anyway by
234+ // giving a reason (`git push -o allow-secrets=<reason>`), and every bypass is audited.
235+ const reason = secretBypassReason(req.pushOptions);
236+ if (reason && caller.role === "admin" && caller.kind === "user") {
237+ messages.push(`Push protection bypassed by ${caller.login}: ${reason}`);
238+ for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
239+ this.emit({ type: "audit", action: "push.secret_bypassed", actor: caller, target, detail: { kinds, reason }, at: now() });
240+ } else {
241+ messages.push("Push blocked: possible secrets detected.");
242+ for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
243+ messages.push("Remove the secret from your commits, or rotate it and contact an admin.");
244+ if (reason) messages.push("Only repository admins (people, not agents) can bypass push protection.");
245+ for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" });
246+ this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target, detail: { kinds }, at: now() });
247+ return reportStatus(req, null, results, messages);
248+ }
238249 }
239250
251+ try {
252+ for (const o of objects) await this.store.writeKnown(o.oid, o.type, o.content);
253+ } catch (e) {
254+ for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" });
255+ return reportStatus(req, (e as Error).message, results);
256+ }
257+
240258 // Validate every command before applying any, so `atomic` is honoured.
241259 const checks: Array<RefResult & { cmd: (typeof req.commands)[number] }> = [];
242260 for (const cmd of req.commands) checks.push({ cmd, ...(await this.checkRefUpdate(cmd.ref, cmd.oldOid, cmd.newOid, caller)) });
modifiedapps/diffinery/test/e2e/e2e.test.mjs+9−0View file
@@ -227,10 +227,19 @@
227227 assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\n");
228228
229229 // ------------------------------------------------------------ secrets are blocked on push
230+ // The key below is a fake that push protection must block in the pushed file; this
231+ // marker exempts only the test source itself: diffinery:allow-secret
230232 writeFileSync(join(clone, "config.env"), "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPL3\n");
231233 git(clone, ["add", "-A"]);
232234 git(clone, ["commit", "-q", "-m", "oops"]);
233235 assert.match(gitFails(clone, ["push", "origin", "main"]), /secret|AWS/i);
236+ // Objects from a blocked push are not kept, so retrying (even with push negotiation) is blocked too.
237+ assert.match(gitFails(clone, ["-c", "push.negotiate=true", "push", "origin", "HEAD:refs/heads/fixture"]), /secret|AWS/i);
238+ // A repository admin can push anyway by giving a reason; the bypass is audited.
239+ assert.match(gitFails(clone, ["push", "-o", "allow-secrets=", "origin", "HEAD:refs/heads/fixture"]), /secret|AWS/i);
240+ git(clone, ["push", "-q", "-o", "allow-secrets=fake key used by this test", "origin", "HEAD:refs/heads/fixture"]);
241+ assert.ok((await ok("GET", `/repos/${owner}/demo/branches`)).some((b) => b.name === "fixture"));
242+ git(clone, ["push", "-q", "origin", ":refs/heads/fixture"]);
234243 git(clone, ["reset", "-q", "--hard", "origin/main"]);
235244
236245 // ------------------------------------------------------------ protect main: PR + green CI + review
addedapps/diffinery/test/unit/secret-scan.test.ts+20−0View file
@@ -1,0 +1,20 @@
1+import { describe, expect, it } from "vitest";
2+import { scanForSecrets, secretBypassReason } from "../../src/git/secret-scan.js";
3+
4+const blob = (text: string) => ({ oid: "a".repeat(40), content: new TextEncoder().encode(text) });
5+
6+describe("push protection", () => {
7+ it("flags credentials, except in files that opt out", () => {
8+ // diffinery:allow-secret (this test file holds fake keys on purpose)
9+ expect(scanForSecrets([blob("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPL3")]).map((h) => h.kind)).toEqual(["AWS access key"]);
10+ expect(scanForSecrets([blob("key = AKIAIOSFODNN7EXAMPL3 # diffinery:allow-secret")])).toEqual([]);
11+ });
12+
13+ it("reads the bypass reason from push options", () => {
14+ expect(secretBypassReason([])).toBeNull();
15+ expect(secretBypassReason(["allow-secrets="])).toBeNull();
16+ expect(secretBypassReason(["allow-secrets= "])).toBeNull();
17+ expect(secretBypassReason(["ci.skip", "allow-secrets= test fixture "])).toBe("test fixture");
18+ expect(secretBypassReason([`allow-secrets=${"x".repeat(300)}`])).toHaveLength(200);
19+ });
20+});
addedscripts/diffinery-wait-runs.sh+48−0View file
@@ -1,0 +1,48 @@
1+#!/usr/bin/env bash
2+# Waits for every Diffinery Actions run on a commit (including runs that other runs
3+# start, like workflow_run followers) and prints each run and job. Prints the logs of
4+# failed jobs and exits non-zero if any run did not succeed.
5+#
6+# Usage: DIFFINERY=https://diffinery.com TOKEN=... diffinery-wait-runs.sh owner/repo sha [minutes]
7+set -euo pipefail
8+repo=$1 sha=$2 minutes=${3:-60}
9+api() { curl -fsS --retry 3 "$DIFFINERY/api/v3/repos/$repo$1" -H "Authorization: Bearer $TOKEN"; }
10+
11+deadline=$(( $(date +%s) + minutes * 60 ))
12+settled=0
13+while :; do
14+ runs=$(api "/actions/runs?head_sha=$sha&per_page=100")
15+ total=$(jq '.workflow_runs | length' <<<"$runs")
16+ pending=$(jq '[.workflow_runs[] | select(.status != "completed")] | length' <<<"$runs")
17+ if [ "$total" -gt 0 ] && [ "$pending" = 0 ]; then
18+ # Finished runs can start followers (workflow_run), so make sure nothing new appears.
19+ settled=$((settled + 1))
20+ [ "$settled" -ge 3 ] && break
21+ else
22+ settled=0
23+ fi
24+ if [ "$(date +%s)" -ge "$deadline" ]; then
25+ echo "::error::Diffinery runs for $sha were not finished after $minutes minutes"
26+ jq -r '.workflow_runs[] | " \(.name) [\(.event)]: \(.status)"' <<<"$runs"
27+ exit 1
28+ fi
29+ sleep 15
30+done
31+
32+failed=0
33+for id in $(jq -r '.workflow_runs[].id' <<<"$runs"); do
34+ run=$(api "/actions/runs/$id")
35+ jq -r '"\(.name) [\(.event)]: \(.conclusion) \(.html_url)"' <<<"$run"
36+ jobs=$(api "/actions/runs/$id/jobs")
37+ jq -r '.jobs[] | " \(.name): \(.conclusion)\(if .failure then " - " + (.failure.message // "") else "" end)"' <<<"$jobs"
38+ if [ "$(jq -r .conclusion <<<"$run")" != success ]; then
39+ failed=1
40+ for job in $(jq -r '.jobs[] | select(.conclusion == "failure" or .conclusion == "timed_out") | .id' <<<"$jobs"); do
41+ echo "::group::Last log lines of job $job"
42+ api "/actions/jobs/$job/logs" | tail -n 120
43+ echo "::endgroup::"
44+ done
45+ fi
46+done
47+[ "$failed" = 0 ] && echo "every Diffinery run on $sha succeeded"
48+exit "$failed"