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
9 changes: 4 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ Starts GitHub Actions workflows on a schedule, from a Cloudflare Workflow. GitHu
- No secrets in the repo. Worker secrets `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`, set
with `task secrets`. Repo secrets `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, for
deploying.
- One App, installed per account: on the `katoptra` organization with access to all
repositories, and on `jshvn` for the repos there. The installation is resolved from each
target's repo name, so nothing in the Worker names an owner.
- One App, installed on `jshvn` for the repos it runs. The installation is resolved from
each target's repo name, so nothing in the Worker names an owner, and a target under
another account is one more installation, not a code change.
- The only network endpoint is `api.github.com`.
- No cron parser. Cloudflare parses the expressions; this repo looks up strings.
- One expression per slot, never a multi-hour daily. Cloudflare hands the Worker the same
Expand Down Expand Up @@ -73,8 +73,7 @@ Each of these fails silently, or only in production.
subrequests per owner per firing, none per extra target.
- **A 404 from the installation lookup means the App is not installed on that repo's
owner.** The step fails for good with `App is not installed on <owner>/<name>`. A repo
created under `katoptra` is covered by that org's all-repositories install; one under
`jshvn` has to be added to that installation by hand.
has to be added to the installation by hand.
- **A dispatch names a ref, and an unnamed one is `main`, not the repo's default branch.**
`dispatchWorkflow` sends `ref: target.ref ?? "main"`; GitHub requires the field, so there is
no asking it for the default. Every target is on `main`, which is what makes the default
Expand Down
12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ Once, by hand.

1. Create an App under `jshvn` with one permission -- Repository permissions -> Actions ->
Read and write -- and no webhook.
2. Install it on the `katoptra` organization with access to all repositories, so a repo
created or transferred there is covered without another step, and on `jshvn` for the
repos that stay there. The Worker finds each target's installation from its repo name at
dispatch time, so there is no installation ID to note.
2. Install it on `jshvn` with access to the repositories it runs. The Worker finds each
target's installation from its repo name at dispatch time, so there is no installation
ID to note, and a target under another account only needs the App installed there.
3. Note the App ID from the App's settings page.
4. Generate a private key and convert it. GitHub issues PKCS#1; WebCrypto imports PKCS#8
only.
Expand Down Expand Up @@ -85,9 +84,8 @@ To add one:

- Write `schedules/<name>.ts` and add its import to `schedules/index.ts`.
- `task crons`, if the slot had no target before.
- Under `katoptra` the App already covers it. Under `jshvn`, add the repo to the App's
installation there. A repo the App cannot see fails its step for good with
`App is not installed on <owner>/<name>`, which `task inspect` shows.
- Add the repo to the App's installation. A repo the App cannot see fails its step for
good with `App is not installed on <owner>/<name>`, which `task inspect` shows.
- Check three things in the target's own workflow. Nothing here can, and a target failing
any of them is dispatched into silence:
- `workflow_dispatch:` in its `on:` block, or the dispatch 404s.
Expand Down
16 changes: 8 additions & 8 deletions test/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ const responding = (status: number, body: string | null = null) => {
describe("repoInstallation", () => {
it("asks for the repo's installation with the App JWT and returns its id", async () => {
const { calls, fetch } = responding(200, JSON.stringify({ id: 12345678 }))
const id = await repoInstallation("jwt", "katoptra/tlnet", fetch)
const id = await repoInstallation("jwt", "acme/alpha", fetch)

expect(id).toBe("12345678")
expect(calls[0]?.url).toBe("https://api.github.com/repos/katoptra/tlnet/installation")
expect(calls[0]?.url).toBe("https://api.github.com/repos/acme/alpha/installation")
expect(calls[0]?.init.method).toBeUndefined()
expect((calls[0]?.init.headers as Record<string, string>).authorization).toBe("Bearer jwt")
})
Expand All @@ -84,33 +84,33 @@ describe("repoInstallation", () => {
// this repo's owner is not one of them. isFatal makes it a hard failure.
it("names the repo and fails for good when the App is not installed there", async () => {
const { fetch } = responding(404, "Not Found")
const err = await repoInstallation("jwt", "katoptra/new-mirror", fetch).catch((e) => e)
expect(err.message).toMatch(/App is not installed on katoptra\/new-mirror/)
const err = await repoInstallation("jwt", "acme/new-repo", fetch).catch((e) => e)
expect(err.message).toMatch(/App is not installed on acme\/new-repo/)
expect(isFatal(err)).toBe(true)
})

it("leaves a server error retryable", async () => {
const { fetch } = responding(502, "bad gateway")
const err = await repoInstallation("jwt", "katoptra/tlnet", fetch).catch((e) => e)
const err = await repoInstallation("jwt", "acme/alpha", fetch).catch((e) => e)
expect(err.message).toMatch(/502/)
expect(isFatal(err)).toBe(false)
})

it("throws when the answer carries no id", async () => {
const { fetch } = responding(200, "{}")
await expect(repoInstallation("jwt", "katoptra/tlnet", fetch)).rejects.toThrow(/no id/)
await expect(repoInstallation("jwt", "acme/alpha", fetch)).rejects.toThrow(/no id/)
})
})

describe("dispatchWorkflow", () => {
const target = { repo: "jshvn/ctan", workflow: "sync.yml" }
const target = { repo: "jshvn/terraform", workflow: "sync.yml" }

it("posts to the dispatches endpoint, defaulting the ref to main", async () => {
const { calls, fetch } = responding(204)
await dispatchWorkflow("tok", target, fetch)

expect(calls[0]?.url).toBe(
"https://api.github.com/repos/jshvn/ctan/actions/workflows/sync.yml/dispatches",
"https://api.github.com/repos/jshvn/terraform/actions/workflows/sync.yml/dispatches",
)
expect(calls[0]?.init.method).toBe("POST")
expect(JSON.parse(calls[0]?.init.body as string)).toEqual({ ref: "main", inputs: {} })
Expand Down
26 changes: 13 additions & 13 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,27 +243,27 @@ describe("Dispatch.run", () => {
})

it("looks up and mints once for a burst of targets under one owner", async () => {
await dispatchTo("katoptra/tlnet", "katoptra/ctan")
await dispatchTo("acme/alpha", "acme/beta")

expect(seen()).toEqual({
lookedUp: ["katoptra/tlnet"],
minted: ["inst-katoptra"],
lookedUp: ["acme/alpha"],
minted: ["inst-acme"],
dispatched: [
["tok-inst-katoptra", "katoptra/tlnet"],
["tok-inst-katoptra", "katoptra/ctan"],
["tok-inst-acme", "acme/alpha"],
["tok-inst-acme", "acme/beta"],
],
})
})

it("keeps two owners on two installations and two tokens", async () => {
await dispatchTo("jshvn/ctan", "katoptra/tlnet")
await dispatchTo("jshvn/terraform", "acme/alpha")

expect(seen()).toEqual({
lookedUp: ["jshvn/ctan", "katoptra/tlnet"],
minted: ["inst-jshvn", "inst-katoptra"],
lookedUp: ["jshvn/terraform", "acme/alpha"],
minted: ["inst-jshvn", "inst-acme"],
dispatched: [
["tok-inst-jshvn", "jshvn/ctan"],
["tok-inst-katoptra", "katoptra/tlnet"],
["tok-inst-jshvn", "jshvn/terraform"],
["tok-inst-acme", "acme/alpha"],
],
})
})
Expand All @@ -272,14 +272,14 @@ describe("Dispatch.run", () => {
// to a hard failure. The message has to name the repo, since that is the whole diagnosis.
it("fails the step for good when the App is not installed on the repo", async () => {
vi.mocked(github.repoInstallation).mockRejectedValueOnce(
new Error("App is not installed on katoptra/new-mirror"),
new Error("App is not installed on acme/new-repo"),
)
vi.mocked(github.isFatal).mockReturnValueOnce(true)

const err = await dispatchTo("katoptra/new-mirror").catch((e) => e)
const err = await dispatchTo("acme/new-repo").catch((e) => e)

expect(err).toBeInstanceOf(NonRetryableError)
expect(err.message).toMatch(/not installed on katoptra\/new-mirror/)
expect(err.message).toMatch(/not installed on acme\/new-repo/)
expect(seen().dispatched).toEqual([])
})
})
Loading