Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ jobs:
[ -n "$ceremony" ] && [ -n "$approve_url" ] || { echo "::error::keeper returned no ceremony"; exit 1; }
echo "ceremony=$ceremony" >> "$GITHUB_OUTPUT"
echo "request=$req" >> "$GITHUB_OUTPUT"
echo "approve_url=$approve_url" >> "$GITHUB_OUTPUT"
# The issue is a NOTIFICATION, not a credential: commenting on it does nothing.
# The approval is the Face ID at the URL.
issue_url="$(gh issue create --repo "$GITHUB_REPOSITORY" \
Expand All @@ -142,6 +143,55 @@ jobs:
echo "issue_url=$issue_url" >> "$GITHUB_OUTPUT"
echo "ceremony open — approve at $approve_url"

# BEFORE the wait below. The issue above is a GitHub notification — batched,
# routed wherever GitHub decides, and no use against a window that can be as
# short as two minutes. infra#552 gave the seven infra ceremony lanes a push;
# this lane was not in that scope, and on 2026-08-31 a ceremony here opened,
# waited its full window and reached no phone (infra#553).
#
# No guard, because the ceremony above has none: this lane has no break-glass
# (infra#18), so if the ceremony ran, an approval is genuinely waiting.
#
# Non-fatal on every path. The gate is the Face ID; a notice that failed to
# send must not fail a deploy that is otherwise authorized.
- name: Notify subscribed devices that an approval is waiting
env:
APPROVE_URL: ${{ steps.ceremony.outputs.approve_url }}
NOTICE_TITLE: Approve bounded.tools deploy
NOTICE_BODY: >-
Mints an account-wide Workers Scripts:Edit token and deploys the GitHub App
receiver — the Worker that fans org activity out to the desk and PR feeds.
run: |
set -euo pipefail
summary() { echo "$1"; echo "$1" >> "$GITHUB_STEP_SUMMARY"; }
if [ -z "${APPROVE_URL:-}" ]; then
summary "Approval notice: skipped — no approve URL from the ceremony."
exit 0
fi
tok="$(curl -sS --max-time 20 \
-H "authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL:-}&audience=https://desk.bounded.tools" \
| jq -r '.value // empty' || true)"
if [ -z "$tok" ]; then
echo "::warning::could not mint an OIDC token for desk; no approval notice sent"
summary "Approval notice: **skipped** — no OIDC token."
exit 0
fi
payload="$(jq -nc --arg t "$NOTICE_TITLE" --arg b "$NOTICE_BODY" \
--arg u "$APPROVE_URL" --arg r "$GITHUB_RUN_ID" \
'{title:$t, body:($b + " Run " + $r + "."), url:$u}')"
code="$(curl -sS --max-time 30 -o /tmp/appr.json -w '%{http_code}' -X POST \
-H "authorization: Bearer $tok" -H 'content-type: application/json' \
-d "$payload" https://desk.bounded.tools/approval || true)"
case "$code" in
200) summary "$(jq -r '"Approval notice: sent \(.sent)/\(.total) — pruned \(.pruned), failed \(.failed)"' /tmp/appr.json)" ;;
403) summary "Approval notice: desk has not allowlisted this lane yet (HTTP 403 — add this workflow's ref to NOTIFY_WORKFLOW_REFS in desk's src/oidc.js and deploy desk)." ;;
404|405|503) summary "Approval notice: not available on the deployed Worker yet (HTTP $code)." ;;
*) echo "::warning::desk /approval answered ${code:-no response}"
summary "Approval notice: **failed** — desk answered ${code:-no response}." ;;
esac
exit 0

- name: Wait for the Face ID, then redeem — fail closed
env:
KEEPER_URL: https://keeper.bounded.tools
Expand Down
56 changes: 56 additions & 0 deletions src/ceremony-notice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Every lane that opens a keeper ceremony must also announce it (infra#553).
//
// The failure this pins is not a broken notice — it is a lane that never had
// one. infra#552 gave seven lanes a push; this repo was not in that scope, and
// on 2026-08-31 a ceremony here opened, waited its full window and reached no
// phone. Nothing was red, because nothing was checked.
//
// It reads the workflow FILES rather than a list of lane names, so a new
// ceremony lane is covered the day it is added rather than the day someone
// remembers to add it here.
import { test, expect } from "bun:test";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const DIR = ".github/workflows";
const workflows = readdirSync(DIR).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));

test("every lane that opens a ceremony pushes an approval notice before waiting", () => {
const missing: string[] = [];
for (const f of workflows) {
const src = readFileSync(join(DIR, f), "utf8");
if (!src.includes("authorize/start")) continue;
// The notice must exist, and must come BEFORE the wait — a notice after the
// wait is sent to a human who has already approved, or never sent at all.
const notice = src.indexOf("desk.bounded.tools/approval");
const wait = src.indexOf("Wait for the Face ID");
// `wait === -1` is a FAILURE, not a reason to skip the ordering check. A
// ceremony lane always waits; if this marker stops matching, the ordering
// assertion would silently disable itself and this test would go on
// passing while proving nothing — found by mutating the step's name.
if (notice === -1 || wait === -1 || notice > wait) missing.push(f);
}
expect(missing).toEqual([]);
});

test("the ceremony step publishes the approve URL the notice needs", () => {
// approve_url was a local shell variable in every lane that had this bug: the
// notice step reads steps.ceremony.outputs.approve_url, so a lane that never
// echoes it sends a notice with an empty URL and reports "skipped" forever.
for (const f of workflows) {
const src = readFileSync(join(DIR, f), "utf8");
if (!src.includes("authorize/start")) continue;
expect(src).toContain('echo "approve_url=$approve_url" >> "$GITHUB_OUTPUT"');
}
});

test("the lane can mint the OIDC token the notice is authorized by", () => {
// A job-level `permissions:` block silently overrides the workflow-level
// grant, and the notice then reports "skipped — no OIDC token" on every run.
// infra#552 found this shape by inspection; here it is a test.
for (const f of workflows) {
const src = readFileSync(join(DIR, f), "utf8");
if (!src.includes("desk.bounded.tools/approval")) continue;
expect(src).toContain("id-token: write");
}
});
54 changes: 45 additions & 9 deletions src/dispatch-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,29 @@ describe("issues — a claim IS the label or the assignee", () => {
});

describe("projects_v2_item — the signal that reaches no other event", () => {
test("card edits wake the private board projection only", () => {
// The public feed derives from the board projection rather than from
// ProjectV2 directly, so waking it here would be a second path to the same
// answer — and a second path is a second thing to keep in agreement.
expect(targets(decide("projects_v2_item", "edited"))).toEqual([".github-private:board-changed"]);
});

test("every card lifecycle action counts", () => {
test("card edits wake BOTH board consumers", () => {
// This pinned `.github-private` alone, on the belief that the public feed
// derives from the board projection and so a second wake-up would be a
// second path to the same answer. There is no first path: front-desk-feed's
// publish.yml queries org project #2 itself, and its own `on:` block records
// that dispatching the private projection lane leaves this feed untouched.
// The assertion was pinning the bug in place, which is why it changes here
// rather than being deleted as an obstacle.
expect(targets(decide("projects_v2_item", "edited"))).toEqual([
".github-private:board-changed",
"front-desk-feed:board-changed",
]);
});

test("every card lifecycle action reaches both, not just `edited`", () => {
// `ok: true` was too weak to catch the missing target — it passed
// throughout the window where a card move reached the public feed through
// no path at all. Assert the fan-out per action instead.
for (const action of ["created", "edited", "deleted", "reordered", "restored"]) {
expect(decide("projects_v2_item", action).ok).toBe(true);
expect(targets(decide("projects_v2_item", action))).toEqual([
".github-private:board-changed",
"front-desk-feed:board-changed",
]);
}
});

Expand All @@ -112,6 +125,29 @@ describe("projects_v2_item — the signal that reaches no other event", () => {
});
});

describe("front-desk-feed — the branch a reader actually looks at", () => {
test("every type this sender can send wakes the feed, none private-only", () => {
// Pinned across the whole DispatchType union rather than case by case,
// because the defect was never one wrong line — it was a type that quietly
// had one consumer fewer than the others. desk.bounded.tools renders the
// `feed` branch and now pushes on a board change, so a type that wakes only
// the private side puts a notification in front of a board that has not
// moved yet. A fourth type should fail here until someone has decided, in
// the open, whether the public feed needs it.
const woken = new Set<string>();
for (const [event, action] of [
["pull_request", "opened"],
["issues", "labeled"],
["projects_v2_item", "edited"],
] as const) {
const r = decide(event, action);
expect(r.ok).toBe(true);
if (r.ok) for (const t of r.targets) if (t.repo === "front-desk-feed") woken.add(t.eventType);
}
expect([...woken].sort()).toEqual(["board-changed", "claim-activity", "pr-activity"]);
});
});

describe("dispatch — transport", () => {
const target: DispatchTarget = { owner: "bounded-systems", repo: ".github-private", eventType: "board-changed" };

Expand Down
41 changes: 35 additions & 6 deletions src/dispatch-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
/** The dispatch types the existing receivers already accept. These are not new
* names — `pr-activity` and `claim-activity` are declared today in
* `.github-private`'s `pr-projection.yml` and `front-desk-feed`'s
* `publish.yml`, and have simply never been sent. `board-changed` is the one
* addition, for ProjectV2 edits, which no receiver could observe before. */
* `publish.yml`, and have simply never been sent. `board-changed` was the one
* addition, for ProjectV2 edits, which no receiver could observe before; both
* receivers declare it now. */
export type DispatchType = "pr-activity" | "claim-activity" | "board-changed";

export type DispatchTarget = { owner: string; repo: string; eventType: DispatchType };
Expand Down Expand Up @@ -67,7 +68,9 @@ const PROJECT_ITEM_ACTIONS = new Set(["created", "edited", "deleted", "reordered
* `.github-private` holds the private projections a session reads;
* `front-desk-feed` publishes the public feed at prs.bounded.tools, whose
* "The backlog is drained" was false for a whole window while 88 PRs were
* open. Waking one and not the other fixes half the symptom. */
* open — and the `feed` branch it force-pushes is also what desk.bounded.tools
* renders as the board. Waking one and not the other fixes half the symptom,
* and the half left broken is the one a reader is actually looking at. */
export function decide(event: string | null, action: string | undefined): DecideResult {
const fanout = (eventType: DispatchType, repos: string[]): DecideResult => ({
ok: true,
Expand All @@ -88,9 +91,35 @@ export function decide(event: string | null, action: string | undefined): Decide
case "projects_v2_item":
if (!action) return { ok: false, reason: "no-action" };
if (!PROJECT_ITEM_ACTIONS.has(action)) return { ok: false, reason: "action-not-watched" };
// The board projection is private-side only; the public feed derives from
// it rather than from ProjectV2 directly.
return fanout("board-changed", [".github-private"]);
// BOTH — and this said private-side only, on a premise that was simply
// wrong: "the public feed derives from [the board projection] rather than
// from ProjectV2 directly". It does not. `front-desk-feed`'s publish.yml
// runs its own `scripts/project.sh` against org project #2, and its `on:`
// block says so in as many words: dispatching `.github-private`'s
// front-desk-projection.yml "does NOT refresh this feed … different repo,
// different branch". So a board change woke the private lane and reached
// the public one through no path at all — the same hole the absent sender
// had, one event further in.
//
// Measured 2026-08-30: the board projection updated at 22:52 and
// desk.bounded.tools went on serving the 21:25 snapshot. That was merely
// stale until desk gained Web Push. Now a board change also sends a
// payload-less push whose service worker fetches the board to learn what
// it was about — so an unwoken feed makes the notification actively
// wrong: it says the board changed and hands the reader a board that has
// not. Worse than not notifying.
//
// BOTH TARGETS NARROW THAT; NEITHER CLOSES IT. The push comes from the
// FIRST target's lane (front-desk-projection.yml is NOTIFY_WORKFLOW_REFS[0]
// in desk's src/oidc.js) and the board it points at is the SECOND target's
// `feed` branch — and this dispatches to both at once, so the fast lane can
// still notify before the slow one has published. front-desk-feed's
// publish.yml mints a broker token, queries the whole board, signs twice
// and force-pushes; it is not going to win that race. Fanning out here
// takes the skew from up to an hour down to the length of that job, which
// is all a sender can do — ordering two repos' Actions lanes is not
// something this Worker can express.
return fanout("board-changed", [".github-private", "front-desk-feed"]);

default:
return { ok: false, reason: "event-not-watched" };
Expand Down
Loading