Ignore silences run emails; import campaign peek, reports and token retry; live test schedule gating (#114)
Ignoring a repository now silences its workflow run emails and notifications too, and the import campaign ignores the test repositories it imports. The campaign report compares Diffinery's string run ids as text, a peek dispatch shows a running campaign's progress and failed jobs, and the token copy retries. The live test's schedule check runs only when the scheduled workflow was pushed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Canberk Demirci authored · GitHub committed signed1 parent e438b3bcommit 63db7ddb059c4a5ab6b48f3bbc02d644d54567a3Browse files
6 files changed+83−16
Files (6)
modified.github/workflows/import-campaign.yml+36−3View file
| @@ -31,12 +31,17 @@ | ||
| 31 | 31 | description: Instead of starting a campaign, print the report and logs of this Diffinery run (its id) |
| 32 | 32 | type: string |
| 33 | 33 | default: "" |
| 34 | + peek: | |
| 35 | + description: Instead of starting a campaign, print how the running one is doing (imports, steps, runs) and stop | |
| 36 | + type: boolean | |
| 37 | + default: false | |
| 34 | 38 | |
| 35 | 39 | permissions: |
| 36 | 40 | contents: read |
| 37 | 41 | |
| 42 | +# A peek runs next to the campaign it looks at; campaigns queue behind each other. | |
| 38 | 43 | concurrency: |
| 39 | - group: import-campaign | |
| 44 | + group: ${{ inputs.peek && 'import-campaign-peek' || 'import-campaign' }} | |
| 40 | 45 | cancel-in-progress: false |
| 41 | 46 | |
| 42 | 47 | jobs: |
| @@ -52,13 +57,13 @@ | ||
| 52 | 57 | R: codician-team/diffinery |
| 53 | 58 | steps: |
| 54 | 59 | - name: Copy IMPORT_GITHUB_TOKEN to Diffinery |
| 55 | - if: ${{ inputs.follow_run == '' }} | |
| 60 | + if: ${{ inputs.follow_run == '' && !inputs.peek }} | |
| 56 | 61 | env: |
| 57 | 62 | IMPORT_GITHUB_TOKEN: ${{ secrets.IMPORT_GITHUB_TOKEN }} |
| 58 | 63 | run: | |
| 59 | 64 | set -euo pipefail |
| 60 | 65 | if [ -n "$IMPORT_GITHUB_TOKEN" ]; then |
| 61 | - jq -n --arg v "$IMPORT_GITHUB_TOKEN" '{value: $v}' | curl -fsS -o /dev/null -X PUT "$DIFFINERY/api/v3/repos/$R/actions/secrets/IMPORT_GITHUB_TOKEN" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @- | |
| 66 | + jq -n --arg v "$IMPORT_GITHUB_TOKEN" '{value: $v}' | curl -fsS --retry 4 --retry-delay 30 -o /dev/null -X PUT "$DIFFINERY/api/v3/repos/$R/actions/secrets/IMPORT_GITHUB_TOKEN" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @- | |
| 62 | 67 | echo "IMPORT_GITHUB_TOKEN is set on GitHub and was copied to Diffinery" |
| 63 | 68 | else |
| 64 | 69 | echo "IMPORT_GITHUB_TOKEN is not set on GitHub; Diffinery's own copy is used if it has one" |
| @@ -69,9 +74,37 @@ | ||
| 69 | 74 | MINUTES: ${{ inputs.minutes }} |
| 70 | 75 | RUN_WORKFLOWS: ${{ inputs.run_workflows }} |
| 71 | 76 | FOLLOW_RUN: ${{ inputs.follow_run }} |
| 77 | + PEEK: ${{ inputs.peek }} | |
| 72 | 78 | run: | |
| 73 | 79 | set -euo pipefail |
| 74 | 80 | api() { curl -fsS --retry 3 -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; } |
| 81 | + if [ "$PEEK" = true ]; then | |
| 82 | + for id in $(api GET "/repos/$R/actions/workflows/import-campaign.yml/runs?per_page=5" | jq -r '.workflow_runs[] | select(.status != "completed") | .id'); do | |
| 83 | + echo "Campaign run $id on Diffinery:" | |
| 84 | + api GET "/repos/$R/actions/runs/$id/jobs" | jq -r '.jobs[] | " job \(.name): \(.status) \(.conclusion // "")"' | |
| 85 | + done | |
| 86 | + owner=$(api GET /user | jq -r .login) | |
| 87 | + since=$(date -u -d '-12 hours' +%Y-%m-%dT%H:%M:%S) | |
| 88 | + imports=$(api GET /diffinery/imports | jq -c --arg since "$since" '[.[] | select((.target.name // "") | startswith("campaign-")) | select(.created_at >= $since)] | sort_by(.created_at)') | |
| 89 | + jq -r '.[] | "\(.target.name): \(.status) (attempt \(.attempt))\(if .error then ": \(.error)" else "" end)", | |
| 90 | + ([.steps[] | select(.status == "running" or .status == "failed")][] | " step \(.title): \(.status) \(.message // "") \(.count // "")"), | |
| 91 | + (if .git then " git \(.git.phase // "") \(.git.message // "") (\(.git.bytes) bytes, \(.git.chunks) chunks, \(.git.tries) tries)" else empty end), | |
| 92 | + (.warnings[]? | " warning: \(.)")' <<<"$imports" | |
| 93 | + # Runs on the imported repositories, with each failed job's reason and the end of its log. | |
| 94 | + for name in $(jq -r '.[] | select(.status == "succeeded") | .target.name' <<<"$imports"); do | |
| 95 | + runs=$(api GET "/repos/$owner/$name/actions/runs?per_page=30") || continue | |
| 96 | + jq -r --arg n "$name" '.workflow_runs[] | "\($n) run \(.name): \(.status) \(.conclusion // "")"' <<<"$runs" | |
| 97 | + for run in $(jq -r '.workflow_runs[] | select(.conclusion == "failure") | .id' <<<"$runs"); do | |
| 98 | + jobs=$(api GET "/repos/$owner/$name/actions/runs/$run/jobs?per_page=100") || continue | |
| 99 | + for job in $(jq -r '.jobs[] | select(.conclusion == "failure") | .id' <<<"$jobs"); do | |
| 100 | + echo "::group::$name: $(jq -r --arg id "$job" '.jobs[] | select((.id | tostring) == $id) | "\(.name)\(if .failure then " (\(.failure.message? // .failure))" else "" end)"' <<<"$jobs")" | |
| 101 | + { api GET "/repos/$owner/$name/actions/jobs/$job/logs" || true; } | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //' | tail -60 | |
| 102 | + echo "::endgroup::" | |
| 103 | + done | |
| 104 | + done | |
| 105 | + done | |
| 106 | + exit 0 | |
| 107 | + fi | |
| 75 | 108 | if [ -n "$FOLLOW_RUN" ]; then |
| 76 | 109 | id=$FOLLOW_RUN |
| 77 | 110 | else |
modified.github/workflows/live-test.yml+4−2View file
| @@ -165,6 +165,7 @@ | ||
| 165 | 165 | echo "created $OWNER/$REPO" |
| 166 | 166 | |
| 167 | 167 | - name: Push workflows and an LFS file |
| 168 | + id: push | |
| 168 | 169 | if: steps.token.outputs.skip != 'true' |
| 169 | 170 | run: | |
| 170 | 171 | set -euo pipefail |
| @@ -592,8 +593,9 @@ | ||
| 592 | 593 | [ "$runs" = 0 ] || { echo "::error::the import started $runs workflow runs"; exit 1; } |
| 593 | 594 | |
| 594 | 595 | - name: Scheduled workflow |
| 595 | - # Runs even when the import check failed: the two are independent. | |
| 596 | - if: ${{ !cancelled() && steps.token.outputs.skip != 'true' }} | |
| 596 | + # Runs even when the import check failed: the two are independent. It needs the scheduled | |
| 597 | + # workflow pushed, though, so a failed setup doesn't add a 13-minute wait for a run that can't come. | |
| 598 | + if: ${{ !cancelled() && steps.token.outputs.skip != 'true' && steps.push.outcome == 'success' }} | |
| 597 | 599 | run: | |
| 598 | 600 | set -euo pipefail |
| 599 | 601 | # The cron trigger fires every 5 minutes, so allow two windows. |
modifiedapps/diffinery/docs/accounts/notifications.md+3−3View file
| @@ -109,7 +109,7 @@ | ||
| 109 | 109 | Rules that apply to every event: |
| 110 | 110 | |
| 111 | 111 | - Your own actions don't notify you, unless you turn on **Email me about my own comments and changes** (email only; such emails carry the reason `your_activity`). |
| 112 | -- A repository set to **Ignore** sends you nothing about its issues and pull requests. | |
| 112 | +- A repository set to **Ignore** sends you nothing about its issues, pull requests and workflow runs. | |
| 113 | 113 | - A muted thread (you clicked **Unsubscribe**) sends you nothing, except when that event @mentions you, assigns you or asks for your review. |
| 114 | 114 | - You must still be able to read the repository. People who lost access stop hearing about it. |
| 115 | 115 | - Agents don't receive email. |
| @@ -122,7 +122,7 @@ | ||
| 122 | 122 | | --- | --- | --- | |
| 123 | 123 | | **Watch: participating** | Participating and @mentions | The default: only threads you take part in | |
| 124 | 124 | | **Watch: all activity** | All activity | Every new issue, pull request, comment, review and state change, plus scheduled workflow runs | |
| 125 | -| **Watch: ignore** | Ignore | Nothing about this repository's issues and pull requests | | |
| 125 | +| **Watch: ignore** | Ignore | Nothing about this repository's issues, pull requests and workflow runs, even runs you started | | |
| 126 | 126 | |
| 127 | 127 | Choosing a level saves it right away ("Watching acme/website: All activity."). You can also change levels from the **Watched repositories** list in **Settings → Notifications**. |
| 128 | 128 | |
| @@ -167,7 +167,7 @@ | ||
| 167 | 167 | | An agent | People watching the repository with **All activity** (the agent's owner isn't notified directly yet) | |
| 168 | 168 | | Diffinery itself (for example a schedule) | People watching the repository with **All activity** | |
| 169 | 169 | |
| 170 | -Failed and timed-out runs send an email and an in-app notification. Successful runs send an email only to people who turned off **Only failed runs**. Cancelled runs send nothing. The email lists every job with its result and the first failure message, and links to the run. | |
| 170 | +Failed and timed-out runs send an email and an in-app notification. Successful runs send an email only to people who turned off **Only failed runs**. Cancelled runs send nothing. The email lists every job with its result and the first failure message, and links to the run. Set a repository to **Ignore** to hear nothing about its runs, for example a test repository whose CI you expect to fail. | |
| 171 | 171 | |
| 172 | 172 | ## Unsubscribe links |
| 173 | 173 |
modifiedapps/diffinery/src/mail/notify.ts+6−6View file
| @@ -9,7 +9,8 @@ | ||
| 9 | 9 | * assignment or review request in the same event still gets through a mute). |
| 10 | 10 | * - Your own actions never notify you, unless you turn on "Include your own updates" for email. |
| 11 | 11 | * - Workflow runs notify whoever triggered them (an agent's owner for agents; |
| 12 | - * watchers for scheduled runs), for failures unless they ask for every run. | |
| 12 | + * watchers for scheduled runs), for failures unless they ask for every run, | |
| 13 | + * and not while they ignore the repository. | |
| 13 | 14 | * - Security emails (sign-ins, passkeys, tokens, agents, push protection) can't be turned off. |
| 14 | 15 | * |
| 15 | 16 | * Every recipient must still be able to read the repository. |
| @@ -171,16 +172,15 @@ | ||
| 171 | 172 | const repo = await dir.repoById(ev.repoId); |
| 172 | 173 | if (!repo) return; |
| 173 | 174 | const store = new MailStore(env.DB); |
| 175 | + const watch = await store.watchers(ev.repoId); | |
| 174 | 176 | let ids: string[] = []; |
| 175 | 177 | if (ev.actor.kind === "user") ids = [ev.actor.id]; |
| 176 | 178 | else if (ev.actor.kind === "agent" && ev.actor.onBehalfOf) { |
| 177 | 179 | const owner = await dir.accountByLogin(ev.actor.onBehalfOf); |
| 178 | 180 | if (owner?.kind === "user") ids = [owner.id]; |
| 179 | - } else { | |
| 180 | - const w = await store.watchers(ev.repoId); | |
| 181 | - ids = w.all; | |
| 182 | - } | |
| 183 | - const accounts = await readable(dir, repo, ids); | |
| 181 | + } else ids = watch.all; | |
| 182 | + // Ignoring a repository silences its runs too, even the ones you started. | |
| 183 | + const accounts = await readable(dir, repo, ids.filter((id) => !watch.ignore.has(id))); | |
| 184 | 184 | if (!accounts.length) return; |
| 185 | 185 | const prefs = await store.prefsFor(accounts.map((a) => a.id)); |
| 186 | 186 | const url = `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}`; |
modifiedapps/diffinery/test/e2e/email.test.mjs+29−0View file
| @@ -254,6 +254,35 @@ | ||
| 254 | 254 | assert.match(failed.subject, new RegExp(`^\\[${owner}/mailbox\\] Run failed: CI - main \\([0-9a-f]{7}\\)$`)); |
| 255 | 255 | assert.equal(failed.dev_copy.headers["X-Diffinery-Reason"], "ci_activity"); |
| 256 | 256 | assert.match(failed.dev_copy.text, /test/); |
| 257 | + | |
| 258 | + // Ignoring the repository silences its runs too, even the ones you pushed. | |
| 259 | + const runOnce = async (file) => { | |
| 260 | + writeFileSync(join(src, file), `${file}\n`); | |
| 261 | + git(src, ["add", "-A"]); | |
| 262 | + git(src, ["commit", "-q", "-m", `Add ${file}`]); | |
| 263 | + git(src, ["push", "-q", "origin", "main"]); | |
| 264 | + const c = await until("a job to claim", async () => { | |
| 265 | + const r = await api("POST", `/repos/${owner}/mailbox/actions/runners/claim`, { name: "e2e", labels: ["linux"] }); | |
| 266 | + return r.status === 200 ? r.json : null; | |
| 267 | + }); | |
| 268 | + await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: c.api_url, DIFFINERY_JOB_ID: c.job_id, DIFFINERY_JOB_TOKEN: c.token, DIFFINERY_WORK_ROOT: join(work, `job-${c.job_id}`) }, 180_000); | |
| 269 | + }; | |
| 270 | + await ok("PUT", `/repos/${owner}/mailbox/subscription`, { ignored: true }); | |
| 271 | + await runOnce("ignored.txt"); | |
| 272 | + const ignoredSha = git(src, ["rev-parse", "--short=7", "HEAD"]).trim(); | |
| 273 | + await until("the ignored run to finish", async () => { | |
| 274 | + const r = await ok("GET", `/repos/${owner}/mailbox/actions/runs?head_sha=${git(src, ["rev-parse", "HEAD"]).trim()}`); | |
| 275 | + return r.workflow_runs[0]?.status === "completed" ? r : null; | |
| 276 | + }); | |
| 277 | + // The run's event goes through two queues (1 s batches here); give them a few rounds. | |
| 278 | + await new Promise((r) => setTimeout(r, 8000)); | |
| 279 | + const ignoredMails = (await emails("&kind=run")).filter((e) => e.subject.includes(`(${ignoredSha})`)); | |
| 280 | + assert.deepEqual(ignoredMails, [], "no email for a run in an ignored repository"); | |
| 281 | + // Back to the default, and run emails come again. | |
| 282 | + await ok("DELETE", `/repos/${owner}/mailbox/subscription`); | |
| 283 | + await runOnce("watched.txt"); | |
| 284 | + const watchedSha = git(src, ["rev-parse", "--short=7", "HEAD"]).trim(); | |
| 285 | + await email("run failed after un-ignoring", (e) => e.kind === "run" && e.subject.includes(`(${watchedSha})`)); | |
| 257 | 286 | }); |
| 258 | 287 | |
| 259 | 288 | test("email: a commit blocked by push protection, made without git, is emailed like a blocked push", async () => { |
modifiedscripts/import-campaign.sh+5−2View file
| @@ -102,6 +102,8 @@ | ||
| 102 | 102 | name=${target[$src]} |
| 103 | 103 | f="$work/$name.import.json" |
| 104 | 104 | [ "$(jq -r .status "$f")" = succeeded ] || continue |
| 105 | + # Test repositories: their runs fail often and shouldn't email whoever runs the campaign. | |
| 106 | + code PUT "/repos/$owner/$name/subscription" '{"ignored":true}' >/dev/null || true | |
| 105 | 107 | repo=$(api GET "/repos/$owner/$name") |
| 106 | 108 | branch=$(jq -r .default_branch <<<"$repo") |
| 107 | 109 | api GET "/repos/$owner/$name/actions/workflows" | jq '[.workflows[] | {name, path, state}]' > "$work/$name.workflows.json" || echo '[]' > "$work/$name.workflows.json" |
| @@ -167,12 +169,13 @@ | ||
| 167 | 169 | if [ "$warnings" != 0 ]; then details+=$'\n'"Warnings:"$'\n'"$(jq -r '.warnings[] | "- \(.)"' "$f")"$'\n'; fi |
| 168 | 170 | [ -f "$work/$name.runs.json" ] || continue |
| 169 | 171 | for run in $(jq -r '.workflow_runs[].id' "$work/$name.runs.json"); do |
| 170 | - r=$(jq -c --argjson id "$run" '.workflow_runs[] | select(.id == $id)' "$work/$name.runs.json") | |
| 172 | + # Diffinery's run and job ids are strings, so they are compared as text. | |
| 173 | + r=$(jq -c --arg id "$run" '.workflow_runs[] | select((.id | tostring) == $id)' "$work/$name.runs.json") | |
| 171 | 174 | details+=$'\n'"### $(jq -r '"\(.name) [\(.event)]: \(.conclusion // .status)"' <<<"$r")"$'\n' |
| 172 | 175 | jobs=$(api GET "/repos/$owner/$name/actions/runs/$run/jobs?per_page=100" || echo '{"jobs":[]}') |
| 173 | 176 | details+="$(jq -r '.jobs[] | "- \(.name): \(.conclusion // .status)\(if .failure then " (\(.failure.message? // .failure))" elif .status != "completed" then " (still \(.status))" else "" end)"' <<<"$jobs")"$'\n' |
| 174 | 177 | for job in $(jq -r '.jobs[] | select(.conclusion == "failure") | .id' <<<"$jobs"); do |
| 175 | - details+=$'\n'"<details><summary>$(jq -r --argjson id "$job" '.jobs[] | select(.id == $id) | .name' <<<"$jobs") log (last 40 lines)</summary>"$'\n\n```\n' | |
| 178 | + details+=$'\n'"<details><summary>$(jq -r --arg id "$job" '.jobs[] | select((.id | tostring) == $id) | .name' <<<"$jobs") log (last 40 lines)</summary>"$'\n\n```\n' | |
| 176 | 179 | details+="$(curl -fsS "$DIFFINERY/api/v3/repos/$owner/$name/actions/jobs/$job/logs" -H "Authorization: Bearer $TOKEN" | tail -40 || echo "(no log)")" |
| 177 | 180 | details+=$'\n```\n</details>\n' |
| 178 | 181 | done |