From 6f8f472da1618da6bd03435a9b2a9241912f7a47 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sun, 13 Sep 2026 16:47:52 +0000 Subject: [PATCH 01/23] ci: group Dependabot security updates for the examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no dependabot.yml, so Dependabot opened one pull request per advisory per manifest: 69 open at the time of writing, 13 of them against the single lockfile in examples/remix/remix-app and 8 against examples/remix-zip. Siblings in the same lockfile conflict as soon as one lands, which is why 13 of the 69 are already CONFLICTING. Each ecosystem present under examples/ now gets a group with `applies-to: security-updates`, so an example app is updated by one pull request instead of eight. `open-pull-requests-limit: 0` disables version updates and leaves security updates on, which preserves today's behavior: PRs for advisories only, not for every dependency that has drifted. The adapter's own Cargo.toml is deliberately absent — it ships as the lambda-adapter binary and its dependencies are reviewed by hand. --- .github/dependabot.yml | 100 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..80fc1281 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,100 @@ +# Dependabot configuration for the example applications. +# +# The adapter itself (Cargo.toml at the repo root) is deliberately NOT managed here: +# it ships as the lambda-adapter binary, and its dependencies are reviewed by hand. +# +# Why this file exists: without it, Dependabot opens one pull request per advisory +# per manifest. That produced 69 open PRs, eight of them against the single lockfile +# in examples/remix/remix-app, which then conflict with each other as soon as one +# lands. The `groups` blocks below batch each ecosystem's security fixes so an +# example app is updated by one PR instead of eight. +# +# `open-pull-requests-limit: 0` disables *version* updates and leaves *security* +# updates on, which keeps the current behavior: Dependabot only opens PRs for +# advisories, not for every dependency that drifts behind. Raise it per ecosystem if +# you later want routine version bumps too. +version: 2 +updates: + - package-ecosystem: npm + directories: + - "/examples/**" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + examples-npm: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: pip + directories: + - "/examples/**" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + examples-pip: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: gomod + directories: + - "/examples/**" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + examples-gomod: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: maven + directories: + - "/examples/**" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + examples-maven: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: nuget + directories: + - "/examples/**" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + examples-nuget: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: cargo + directories: + - "/examples/**" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + examples-cargo: + applies-to: security-updates + patterns: + - "*" From 52896aff249c0483269ef06bffabae6510fb4079 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sun, 13 Sep 2026 16:47:52 +0000 Subject: [PATCH 02/23] ci: verify only the examples a pull request touches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify Examples ran all 18 matrix entries for any change under examples/, so a lockfile bump in examples/remix/remix-app rebuilt and booted springboot, nextjs, deno-zip and the rest. At ~70 open Dependabot pull requests that is the dominant CI cost, and none of it is signal. A `select` job now diffs the pull request and emits one matrix per job kind, so that bump runs a single job. The example lists move to .github/example-matrix.json so the selector and the matrices share one source of truth. The selector fails safe — no base commit, a base commit that is not available locally, or a change to shared code (src/, layer/, Cargo.toml, the workflow itself, the matrix file) all verify everything. Pushes to main and manual runs are unaffected: they have no base commit and so verify everything. Adds `examples-verified`, one aggregate result for the whole workflow, treating `skipped` as a pass since that is what a filtered-out matrix means. Verified against real commits: a bump under examples/remix/remix-app selects only remix, the SnapStart merge (src/) selects everything, and a bump under an example with no matrix entry selects nothing. --- .github/example-matrix.json | 26 +++++++++ .github/scripts/select-examples.sh | 62 +++++++++++++++++++++ .github/workflows/examples.yaml | 88 ++++++++++++++++++++++-------- 3 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 .github/example-matrix.json create mode 100755 .github/scripts/select-examples.sh diff --git a/.github/example-matrix.json b/.github/example-matrix.json new file mode 100644 index 00000000..6c4f8d97 --- /dev/null +++ b/.github/example-matrix.json @@ -0,0 +1,26 @@ +{ + "image": [ + { "name": "expressjs", "path": "/", "expect_body": "Hi there!" }, + { "name": "fastapi", "path": "/", "expect_body": "message" }, + { "name": "fastapi-background-tasks", "path": "/", "expect_body": "message" }, + { "name": "fasthtml", "path": "/", "expect_body": "Hello World" }, + { "name": "gin", "path": "/", "expect_body": "message" }, + { "name": "nextjs", "path": "/", "expect_body": "Next.js Logo" }, + { "name": "remix", "path": "/", "expect_body": "Welcome to" }, + { "name": "springboot", "path": "/v1/", "expect_body": "Hello, world!" } + ], + "zip": [ + { "name": "deno-zip", "path": "/", "expect_body": "success", "port": "8000" }, + { "name": "expressjs-zip", "path": "/", "expect_body": "Hi there!", "port": "8000" }, + { "name": "fastapi-zip", "path": "/", "expect_body": "message", "port": "8000" }, + { "name": "fasthtml-zip", "path": "/", "expect_body": "Hello World", "port": "8000" }, + { "name": "flask-zip", "path": "/", "expect_body": "message", "port": "8000" }, + { "name": "gin-zip", "path": "/", "expect_body": "message", "port": "8000" }, + { "name": "remix-zip", "path": "/", "expect_body": "Welcome to", "port": "8000" }, + { "name": "springboot-zip", "path": "/v1/", "expect_body": "Hello, world!", "port": "8000" } + ], + "stream": [ + { "name": "fasthtml-response-streaming", "kind": "image", "path": "/", "expect_body": "Serverless Bedtime" }, + { "name": "fasthtml-response-streaming-zip", "kind": "zip-fasthtml", "path": "/", "expect_body": "Click to stream" } + ] +} diff --git a/.github/scripts/select-examples.sh b/.github/scripts/select-examples.sh new file mode 100755 index 00000000..f2590b4a --- /dev/null +++ b/.github/scripts/select-examples.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Picks which examples the Verify Examples workflow needs to run, and writes one +# matrix per job kind (image, zip, stream) to $GITHUB_OUTPUT. +# +# A dependency bump under examples/remix/remix-app has no bearing on springboot or +# deno-zip, so verifying all 18 matrix entries for it burns runners for no signal. +# With ~70 open Dependabot PRs against the examples that cost dominates CI. +# +# Fails safe: anything this script cannot resolve confidently — no base commit, a +# base commit not present locally, a change to shared code — verifies everything. +# +# Inputs: +# BASE_SHA base commit to diff against; empty means "verify everything" +# GITHUB_OUTPUT set by Actions +set -euo pipefail + +MATRIX="$(dirname "$0")/../example-matrix.json" + +emit_all() { + local kind + for kind in image zip stream; do + echo "$kind=$(jq -c ".$kind" "$MATRIX")" >>"$GITHUB_OUTPUT" + done +} + +if [[ -z "${BASE_SHA:-}" ]]; then + echo "No base commit (push or manual run): verifying every example." + emit_all + exit 0 +fi + +if ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then + echo "Base commit $BASE_SHA is not available locally: verifying every example." + emit_all + exit 0 +fi + +# HEAD is the pull request's merge commit, so diffing from the merge base yields +# exactly the changes the PR contributes. +base="$(git merge-base "$BASE_SHA" HEAD)" +changed="$(git diff --name-only "$base" HEAD)" +echo "Changed files:" +echo "$changed" | sed 's/^/ /' + +# Shared inputs every example is built against: the adapter itself, the layer +# wrapper, this workflow's own machinery. +if grep -qE '^(src/|layer/|Cargo\.toml$|Cargo\.lock$|\.github/workflows/examples\.yaml$|\.github/scripts/|\.github/example-matrix\.json$)' <<<"$changed"; then + echo "A shared path changed: verifying every example." + emit_all + exit 0 +fi + +# examples//... -> +names="$(grep -oE '^examples/[^/]+' <<<"$changed" | cut -d/ -f2 | sort -u | jq -R . | jq -sc .)" +echo "Changed examples: $names" + +for kind in image zip stream; do + matrix="$(jq -c --argjson names "$names" "[.$kind[] | select(.name as \$n | \$names | index(\$n))]" "$MATRIX")" + echo "$kind=$matrix" + echo "$kind=$matrix" >>"$GITHUB_OUTPUT" +done diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index bfc4af71..af13d380 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -20,6 +20,28 @@ env: CARGO_TERM_COLOR: always jobs: + # Narrows the test matrices to the examples a pull request actually touches, so a + # dependency bump in one example does not rebuild and boot all eighteen. Any change + # to shared code (src/, layer/, this workflow) still verifies everything. See + # .github/scripts/select-examples.sh. + select: + runs-on: ubuntu-24.04 + outputs: + image: ${{ steps.filter.outputs.image }} + zip: ${{ steps.filter.outputs.zip }} + stream: ${{ steps.filter.outputs.stream }} + steps: + - uses: actions/checkout@v4 + with: + # Needed to diff against the base commit rather than a shallow clone. + fetch-depth: 0 + + - name: Select the examples to verify + id: filter + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: .github/scripts/select-examples.sh + validate: runs-on: ubuntu-24.04 steps: @@ -82,20 +104,13 @@ jobs: # Excluded: nginx, flask, aspnet-mvc (web app hardcodes port 8080 # which conflicts with SAM's Lambda Runtime Interface Emulator). test-image: - needs: [build-layer] + needs: [select, build-layer] + if: needs.select.outputs.image != '[]' runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - example: - - { name: expressjs, path: /, expect_body: "Hi there!" } - - { name: fastapi, path: /, expect_body: "message" } - - { name: fastapi-background-tasks, path: /, expect_body: "message" } - - { name: fasthtml, path: /, expect_body: "Hello World" } - - { name: gin, path: /, expect_body: "message" } - - { name: nextjs, path: /, expect_body: "Next.js Logo" } - - { name: remix, path: /, expect_body: "Welcome to" } - - { name: springboot, path: /v1/, expect_body: "Hello, world!" } + example: ${{ fromJSON(needs.select.outputs.image) }} steps: - uses: actions/checkout@v4 @@ -178,20 +193,13 @@ jobs: # bun/nginx (need third-party layers), arm64 examples (javalin, rust-*), # nextjs-zip (Makefile produces a zip artifact incompatible with sam local). test-zip: - needs: [build-layer] + needs: [select, build-layer] + if: needs.select.outputs.zip != '[]' runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - example: - - { name: deno-zip, path: /, expect_body: "success", port: "8000" } - - { name: expressjs-zip, path: /, expect_body: "Hi there!", port: "8000" } - - { name: fastapi-zip, path: /, expect_body: "message", port: "8000" } - - { name: fasthtml-zip, path: /, expect_body: "Hello World", port: "8000" } - - { name: flask-zip, path: /, expect_body: "message", port: "8000" } - - { name: gin-zip, path: /, expect_body: "message", port: "8000" } - - { name: remix-zip, path: /, expect_body: "Welcome to", port: "8000" } - - { name: springboot-zip, path: /v1/, expect_body: "Hello, world!", port: "8000" } + example: ${{ fromJSON(needs.select.outputs.zip) }} steps: - uses: actions/checkout@v4 @@ -296,14 +304,13 @@ jobs: # AWS_REGION is set so boto3 / AnthropicBedrock clients construct without real # creds; the verified routes only render UI and never call Bedrock. test-stream: - needs: [build-layer] + needs: [select, build-layer] + if: needs.select.outputs.stream != '[]' runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - example: - - { name: fasthtml-response-streaming, kind: image, path: /, expect_body: "Serverless Bedtime" } - - { name: fasthtml-response-streaming-zip, kind: zip-fasthtml, path: /, expect_body: "Click to stream" } + example: ${{ fromJSON(needs.select.outputs.stream) }} steps: - uses: actions/checkout@v4 @@ -366,3 +373,36 @@ jobs: run: | docker rm -f stream-app 2>/dev/null || true kill $APP_PID 2>/dev/null || true + + # One aggregate result for the whole workflow, so a reviewer (and the auto-merge + # workflow) has a single thing to look at: the matrix jobs' names embed their + # parameters ("test-zip (deno-zip, /, success, 8000)") and vanish entirely when + # `select` filters an example out, so there is no stable per-example name to read. + # + # `skipped` counts as a pass — that is what a filtered-out matrix means. `failure` + # and `cancelled` do not. + # + # Note if you ever make this a required check in branch protection: this workflow is + # path-filtered to examples/**, so it never runs on a source-only pull request and + # the check would never report there, blocking the pull request indefinitely. That + # is why Dependabot auto-merge keys off this workflow's completed run instead. + examples-verified: + if: always() + needs: [select, validate, build-layer, test-image, test-zip, test-stream] + runs-on: ubuntu-24.04 + steps: + - name: Check the verification results + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "job results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success | skipped) ;; + *) + echo "A verification job reported '$result'." + exit 1 + ;; + esac + done + echo "All example verification jobs passed." From ccefd41d87f99ccbaabbfc961c069d4f04dec62a Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sun, 13 Sep 2026 16:47:52 +0000 Subject: [PATCH 03/23] ci: auto-merge verified example-only Dependabot updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges a Dependabot pull request once Verify Examples has gone green on it, if every file it changes is under examples/ and at least one build-and-boot job actually ran. Keyed off the completed workflow run rather than `gh pr merge --auto`: auto-merge is gated on the repository's *required* status checks, and Verify Examples is path-filtered to examples/**, so requiring its result would never report on a source-only pull request and would block it forever. The completed run is also tied to the head commit being merged, which addresses the stale verdict problem — PRs opened months ago still carry check results from the main of that day (#827 and older show validate:FAILURE for exactly that reason). Three guards, all necessary: * author is Dependabot; * every changed file is under examples/, checked against the PR's file list rather than its branch name, because grouped updates do not reliably encode the directory in the ref; * at least one test-* job succeeded, so an example with no matrix entry cannot ride in on a green run that only validated templates. Scope is deliberate: examples are demo apps, where a bad bump costs a broken sample. The adapter's own dependencies, the workflows, and the layer templates stay manual. --- .github/workflows/dependabot-automerge.yaml | 78 +++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/dependabot-automerge.yaml diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml new file mode 100644 index 00000000..baaba0dd --- /dev/null +++ b/.github/workflows/dependabot-automerge.yaml @@ -0,0 +1,78 @@ +name: Dependabot Auto-merge + +# Merges Dependabot pull requests that only touch example applications, once Verify +# Examples has actually verified them. +# +# Why workflow_run rather than `gh pr merge --auto`: auto-merge is gated on a +# repository's *required* status checks, and Verify Examples is path-filtered to +# examples/**. A required check from a path-filtered workflow never reports on pull +# requests outside those paths, which would block every source-only pull request +# forever. Keying off the completed run sidesteps that: the run itself is the evidence, +# and it is tied to the head commit that will be merged. +# +# Requires no branch protection and no repository settings. +on: + workflow_run: + workflows: ["Verify Examples"] + types: + - completed + +permissions: + contents: write + pull-requests: write + +jobs: + merge: + # Only a green pull request run can merge anything. A push run has no pull request + # to merge, and a failed run is the whole point of the gate. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + steps: + - name: Merge if this is an example-only Dependabot update that was verified + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + run: | + set -euo pipefail + + if [[ -z "${PR_NUMBER:-}" ]]; then + echo "Run is not associated with a pull request; nothing to merge." + exit 0 + fi + + author=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json author -q .author.login) + if [[ "$author" != "app/dependabot" && "$author" != "dependabot[bot]" ]]; then + echo "PR #$PR_NUMBER is authored by $author, not Dependabot. Skipping." + exit 0 + fi + + # Scope by what the PR actually changes rather than by its branch name: + # grouped updates do not reliably encode the directory in the ref. Examples + # are demo apps, so a bad bump costs a broken sample; the adapter's own + # dependencies, the workflows, and the templates stay manual. + outside=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" -q '.[].filename' \ + | grep -v '^examples/' || true) + if [[ -n "$outside" ]]; then + echo "PR #$PR_NUMBER changes files outside examples/:" + echo "$outside" | sed 's/^/ /' + exit 0 + fi + + # Refuse to merge on template validation alone. If `select` filtered every + # matrix entry out, the changed example is not covered by a build-and-boot + # test, and a human should look at it. This is what keeps an uncovered + # example (nextjs-zip, say) from riding in on a green-but-empty run. + verified=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs" --paginate \ + -q '[.jobs[] | select(.name | startswith("test-")) | select(.conclusion == "success")] | length') + if [[ "$verified" -eq 0 ]]; then + echo "No test-* job ran for PR #$PR_NUMBER: the changed example has no" + echo "build-and-boot coverage, so this needs a human. Skipping." + exit 0 + fi + + echo "PR #$PR_NUMBER is example-only and passed $verified verification job(s). Merging." + gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch From fa41e92afda0b4c85dc3977d37e5f2c88af27311 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sun, 13 Sep 2026 16:53:49 +0000 Subject: [PATCH 04/23] ci: document the verified Dependabot grouping semantics Records the two behaviors this config depends on, both now confirmed rather than assumed: Grouping is per directory. PRs #804 and #811 carry the identical update set (body-parser + express) and even the identical branch hash multi-be700a2db9, yet Dependabot raised them as two separate PRs, one per directory. Cross-directory batching requires `group-by: dependency-name`, which applies to version updates only. So this config yields one PR per example app per ecosystem. `open-pull-requests-limit: 0` stops version updates without stopping security updates: those are exempt from the limit and do not count toward it. --- .github/dependabot.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 80fc1281..a4af5966 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,10 +9,17 @@ # lands. The `groups` blocks below batch each ecosystem's security fixes so an # example app is updated by one PR instead of eight. # +# Grouping is per directory: Dependabot treats each (ecosystem, directory) pair as its +# own update and does not batch across directories unless `group-by: dependency-name` +# is set, which applies to version updates only. So this yields one pull request per +# example app per ecosystem, which is the intent — a broken bump then fails one +# example's verification instead of blocking every example's fixes at once. +# # `open-pull-requests-limit: 0` disables *version* updates and leaves *security* # updates on, which keeps the current behavior: Dependabot only opens PRs for -# advisories, not for every dependency that drifts behind. Raise it per ecosystem if -# you later want routine version bumps too. +# advisories, not for every dependency that drifts behind. Security updates are +# explicitly exempt from this limit and do not count toward it. Raise it per ecosystem +# if you later want routine version bumps too. version: 2 updates: - package-ecosystem: npm From f31457332f1c6139827a473ad3b0bcba211b0843 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sun, 13 Sep 2026 17:45:45 +0000 Subject: [PATCH 05/23] ci: harden the Dependabot auto-merge guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review on #844, all in the merge step. Pin the merge to the verified commit. The evidence came from a workflow run tied to workflow_run.head_sha, but `gh pr merge` merged whatever the head was when the API call ran. Dependabot force-pushes its branches on rebase and recreate, so the head can move while the run finishes, and the result would be an unverified commit squashed into main. The step now compares the current head to the verified one and skips if it moved, and passes --match-head-commit to close the remaining window. Make the scope guard fail closed. `outside=$(gh api ... | grep -v '^examples/' || true)` applied `|| true` to the whole pipeline, so a rate-limited or failed API call left `outside` empty and the pull request read as example-only — the one check keeping the adapter's Cargo.toml, the workflows, and the layer templates out of auto-merge. The API call is now separate from the filtering, with an explicit refusal on an empty list. Confirmed by simulation: the old form merges on an API failure, the new form does not. Gate on coverage of the changed set, not a job count. "At least one test-* job succeeded" was weaker than its comment claimed: select-examples.sh silently drops changed examples with no matrix entry, so a pull request touching one covered and one uncovered example passed while the second was never built or booted. Every changed example must now appear in .github/example-matrix.json, read at the verified commit. Not reachable with today's config — all 69 open Dependabot pull requests touch exactly one example, and grouping is per directory — but it becomes reachable the moment grouping spans directories, and the job count is the wrong thing to assert either way. All six guard paths exercised against a stubbed gh: merge, moved head, files outside examples/, uncovered example, covered-plus-uncovered, and no test jobs. --- .github/workflows/dependabot-automerge.yaml | 70 ++++++++++++++++----- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index baaba0dd..3b2aa389 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -3,12 +3,12 @@ name: Dependabot Auto-merge # Merges Dependabot pull requests that only touch example applications, once Verify # Examples has actually verified them. # -# Why workflow_run rather than `gh pr merge --auto`: auto-merge is gated on a -# repository's *required* status checks, and Verify Examples is path-filtered to -# examples/**. A required check from a path-filtered workflow never reports on pull -# requests outside those paths, which would block every source-only pull request -# forever. Keying off the completed run sidesteps that: the run itself is the evidence, -# and it is tied to the head commit that will be merged. +# Keyed off the completed workflow run rather than `gh pr merge --auto`: auto-merge is +# gated on the repository's *required* status checks, and Verify Examples is +# path-filtered to examples/**, so requiring its result would never report on a +# source-only pull request and would block it forever. The completed run is also tied +# to a specific commit, which is what lets the merge below be pinned to the commit that +# was verified. # # Requires no branch protection and no repository settings. on: @@ -35,6 +35,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} RUN_ID: ${{ github.event.workflow_run.id }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} run: | set -euo pipefail @@ -50,29 +51,64 @@ jobs: exit 0 fi - # Scope by what the PR actually changes rather than by its branch name: + # Everything below reasons about $HEAD_SHA, the commit this run verified — + # never "whatever the head is now". Dependabot force-pushes its branches when + # it rebases or recreates them, so the head can move while the run is + # finishing, and merging the new head would merge something unverified. + head_now=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid -q .headRefOid) + if [[ "$head_now" != "$HEAD_SHA" ]]; then + echo "PR #$PR_NUMBER moved from $HEAD_SHA to $head_now since it was verified." + echo "That push starts its own Verify Examples run; leaving the merge to it." + exit 0 + fi + + # Fail closed: an empty file list must never read as "nothing outside + # examples/". Keep the API call out of the pipeline so that only grep's + # no-match status is tolerated, not a failed or rate-limited request. + files=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" -q '.[].filename') + if [[ -z "$files" ]]; then + echo "Could not list the files for PR #$PR_NUMBER; refusing to merge." + exit 0 + fi + + # Scope by what the pull request changes rather than by its branch name: # grouped updates do not reliably encode the directory in the ref. Examples # are demo apps, so a bad bump costs a broken sample; the adapter's own # dependencies, the workflows, and the templates stay manual. - outside=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" -q '.[].filename' \ - | grep -v '^examples/' || true) + outside=$(grep -v '^examples/' <<<"$files" || true) if [[ -n "$outside" ]]; then echo "PR #$PR_NUMBER changes files outside examples/:" echo "$outside" | sed 's/^/ /' exit 0 fi - # Refuse to merge on template validation alone. If `select` filtered every - # matrix entry out, the changed example is not covered by a build-and-boot - # test, and a human should look at it. This is what keeps an uncovered - # example (nextjs-zip, say) from riding in on a green-but-empty run. + # Refuse to merge an example that is not built and booted. select-examples.sh + # silently drops changed examples with no matrix entry, so a job count alone + # would pass a pull request that touches one covered and one uncovered + # example while never exercising the second. Require coverage for every + # changed example, read at the verified commit. + changed_examples=$(cut -d/ -f2 <<<"$files" | sort -u) + covered=$(gh api "repos/$REPO/contents/.github/example-matrix.json?ref=$HEAD_SHA" \ + -H 'Accept: application/vnd.github.raw' -q '[.[][].name] | unique | .[]' | sort -u) + uncovered=$(comm -23 <(printf '%s\n' "$changed_examples") <(printf '%s\n' "$covered") || true) + if [[ -n "$uncovered" ]]; then + echo "PR #$PR_NUMBER changes examples with no build-and-boot coverage:" + echo "$uncovered" | sed 's/^/ /' + echo "Add them to .github/example-matrix.json, or review this by hand." + exit 0 + fi + + # Covered examples are only actually verified if their jobs ran. verified=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs" --paginate \ -q '[.jobs[] | select(.name | startswith("test-")) | select(.conclusion == "success")] | length') if [[ "$verified" -eq 0 ]]; then - echo "No test-* job ran for PR #$PR_NUMBER: the changed example has no" - echo "build-and-boot coverage, so this needs a human. Skipping." + echo "No test-* job succeeded in run $RUN_ID; refusing to merge." exit 0 fi - echo "PR #$PR_NUMBER is example-only and passed $verified verification job(s). Merging." - gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch + echo "PR #$PR_NUMBER is example-only, fully covered, and passed $verified verification job(s)." + # --match-head-commit closes the remaining window: if the branch moves + # between the check above and this call, the merge is refused rather than + # applied to an unverified commit. + gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch \ + --match-head-commit "$HEAD_SHA" From 33c1cb3118e6426427f3ab8fb7b0b6ff53295a1d Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Sun, 13 Sep 2026 18:00:33 +0000 Subject: [PATCH 06/23] ci: grant actions:read, count jobs safely, cover bundler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the second review on #844. Add `actions: read`. Declaring a permissions block sets every unnamed scope to none, and the coverage gate lists the triggering run's jobs, so the Actions API would have returned 403 and aborted the step on every Dependabot pull request. It fails closed, but the automation would never have merged anything. commitlint-comment.yaml declares the same scope for the same reason. Count job names instead of asking jq for a length. `--paginate` applies `-q` per page, so a run spanning two pages yields one count per line ("18\n4"), and `[[ "18\n4" -eq 0 ]]` is an arithmetic syntax error that evaluates false — skipping the refusal and merging. Verified in bash: the multi-line form errors with "syntax error in expression" and takes the else branch. Single-page today at 22 jobs, but the failure direction is fail-open, and the headroom is smaller than it looks. Add the bundler ecosystem for examples/sinatra/app/src/Gemfile, which the original sweep missed. Beyond grouping, this is what gives those PRs a conventional commit prefix: Commit Lint runs on every pull request with no path filter, and #799 shows what the default message costs — commit "bump com.fasterxml.jackson.core:jackson-databind", Lint Commit Messages red. --- .github/dependabot.yml | 19 +++++++++++++++++++ .github/workflows/dependabot-automerge.yaml | 15 +++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a4af5966..07be2a77 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -105,3 +105,22 @@ updates: applies-to: security-updates patterns: - "*" + + # examples/sinatra/app/src/Gemfile. Easy to miss, and the cost of missing it is not + # just ungrouped PRs: without `prefix: chore` Dependabot writes "bump rack from ...", + # which has no conventional type, and Commit Lint runs on every pull request with no + # path filter. #799 is the evidence — its commit message is + # "bump com.fasterxml.jackson.core:jackson-databind" and its Commit Lint check is red. + - package-ecosystem: bundler + directories: + - "/examples/**" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + examples-bundler: + applies-to: security-updates + patterns: + - "*" diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index 3b2aa389..e94a3647 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -20,6 +20,11 @@ on: permissions: contents: write pull-requests: write + # Required to list the triggering run's jobs. Declaring a permissions block sets + # every scope not named here to none, so without this the Actions API returns 403 + # and the step aborts on every Dependabot pull request. Same reason + # commitlint-comment.yaml declares it. + actions: read jobs: merge: @@ -98,9 +103,15 @@ jobs: exit 0 fi - # Covered examples are only actually verified if their jobs ran. + # Covered examples are only actually verified if their jobs ran. Count lines + # rather than asking jq for a length: --paginate applies -q per page, so a + # run spanning two pages would yield one count per line ("18\n4"), and + # `[[ "18\n4" -eq 0 ]]` is an arithmetic syntax error that evaluates false — + # skipping the refusal below and merging. Only the job total keeps this + # single-page today, which is not a property worth depending on. verified=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs" --paginate \ - -q '[.jobs[] | select(.name | startswith("test-")) | select(.conclusion == "success")] | length') + -q '.jobs[] | select(.name | startswith("test-")) | select(.conclusion == "success") | .name' \ + | wc -l) if [[ "$verified" -eq 0 ]]; then echo "No test-* job succeeded in run $RUN_ID; refusing to merge." exit 0 From 38e46fd2b908d022869c03d4bced843d750d3bd2 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 16:23:47 +0000 Subject: [PATCH 07/23] ci: make Verify Examples self-verifying and gate on every check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the third review on #844. Trigger Verify Examples on its own machinery. The workflow only ran for examples/**, so a pull request changing example-matrix.json or select-examples.sh — now the single source of truth for what gets verified — never ran it. This pull request is the proof: it rewires all three matrices and its checks are Commit Lint, CodeQL and pr.yaml only, no validate or test-* at all. The matrix file, the selector directory and this workflow are now triggers, so it verifies its own changes. src/, layer/ and Cargo.* are deliberately still not pull request triggers even though the selector treats them as shared: adding them would run all eighteen matrix entries on every source pull request. Adapter changes stay verified against the examples on push to main, and the shared-path rule still applies to a pull request touching both. Gate on the whole check rollup, not just this one run. Commit Lint runs on every pull request with no path filter and does go red on Dependabot PRs (#799), yet the merge consulted only the Verify Examples run, and any check added later would have been ignored too. Anything not SUCCESS/SKIPPED/NEUTRAL — including still running, since a workflow_run job cannot wait — now refuses the merge; Dependabot rebases these branches often and any later run re-evaluates. Verified against live data: the query is empty for #842 (all green, CodeQL NEUTRAL) and names the failure on #844. This workflow's own run is excluded defensively, since an in-progress self-check would deadlock every merge if workflow_run runs ever joined the rollup. Add the github-actions ecosystem for the commit prefix, for the same reason bundler was added: every workflow pins actions, so an advisory would open a PR with no conventional type and a red Commit Lint. With the limit at 0 it adds no pull requests. Pre-emptive — there are no open actions alerts today (the 705 open alerts are npm, pip, go, rubygems, rust and maven). --- .github/dependabot.yml | 20 +++++++++++++++++++ .github/workflows/dependabot-automerge.yaml | 22 +++++++++++++++++++++ .github/workflows/examples.yaml | 13 ++++++++++++ 3 files changed, 55 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 07be2a77..897a0a08 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -124,3 +124,23 @@ updates: applies-to: security-updates patterns: - "*" + + # Not an example, and listed for the naming alone. Every workflow here pins actions + # (actions/checkout@v4, Swatinem/rust-cache@v2, orhun/git-cliff-action@v4), so an + # actions advisory would open a PR titled "bump actions/... from X to Y" — no + # conventional type, red Commit Lint, manual amend. With the limit at 0 this adds no + # pull requests; it only names the ones an advisory would produce anyway. There are + # no open actions alerts today, so this is pre-emptive. + - package-ecosystem: github-actions + directories: + - "/" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: ci + groups: + github-actions: + applies-to: security-updates + patterns: + - "*" diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index e94a3647..17f36ed4 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -103,6 +103,28 @@ jobs: exit 0 fi + # Verify Examples is not the only check on the pull request. Commit Lint runs + # on every pull request with no path filter and does go red on Dependabot + # PRs (#799), and any check added later would be ignored here too. Anything + # not green — including still running, since this workflow cannot wait — means + # leave it alone; Dependabot rebases these branches often, and any later + # Verify Examples run re-evaluates the pull request. + # + # This workflow's own run is excluded defensively. workflow_run runs do not + # currently appear in a pull request's check rollup, but if that changed, its + # in-progress state would deadlock every merge. + not_green=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json statusCheckRollup -q ' + .statusCheckRollup[] + | select((.workflowName // "") != "Dependabot Auto-merge") + | select([((.conclusion // .state // "PENDING") | ascii_upcase)] + - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0) + | ((.name // .context) + " = " + (.conclusion // .state // "PENDING"))') + if [[ -n "$not_green" ]]; then + echo "PR #$PR_NUMBER has checks that are not green; refusing to merge:" + echo "$not_green" | sed 's/^/ /' + exit 0 + fi + # Covered examples are only actually verified if their jobs ran. Count lines # rather than asking jq for a length: --paginate applies -q per page, so a # run spanning two pages would yield one count per line ("18\n4"), and diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index af13d380..d6bb3ba6 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -6,11 +6,24 @@ on: - main paths: - "examples/**" + # This workflow's own machinery. The matrix file and the selector decide what + # gets verified at all, so a pull request that changes only those would + # otherwise not run here — and a malformed matrix or a selector regression + # would land unverified and surface later on an unrelated examples pull + # request. Including them makes the workflow verify its own changes. + - ".github/workflows/examples.yaml" + - ".github/scripts/**" + - ".github/example-matrix.json" push: branches: - main paths: - "src/**" + # Note that src/, layer/ and Cargo.* are deliberately NOT pull request triggers, + # even though select-examples.sh treats them as shared: adding them would run all + # eighteen matrix entries on every source pull request. Adapter changes are verified + # against the examples on push to main (above), and the selector's shared-path rule + # still applies to a pull request that touches both source and examples. workflow_dispatch: permissions: From 9be72a5d5ab8fa4fe7d6867d519e56da27b66841 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 16:39:21 +0000 Subject: [PATCH 08/23] ci: keep both fail-safe paths from aborting instead of refusing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from the fourth review on #844. Both are cases where a condition the code intends to handle deliberately instead killed the step with no explanation. Read the example matrix from the default branch, not from the verified head. A Dependabot branch cut before this file existed 404s at its own head, and gh's non-zero exit aborted the whole step under set -e with a bare "Not Found" rather than the refusal every other unresolvable condition here gets. Reachable immediately: confirmed a 404 against #842's current head, and all 69 open Dependabot PRs branch from a main that predates the file. The default branch is also the right source of truth — it is repository config, the examples-only guard means the pull request cannot have changed it, and a pull_request workflow runs the merge-ref copy, so it is the matrix the run actually used. A failed read or a malformed file now refuses explicitly. Stop the selector aborting when nothing under examples/ changed. `grep -oE` exits 1 on no match and pipefail turned that into a red Verify Examples with no diagnostic, contradicting the fail-safe contract documented at the top of the script. Reproduced with an empty diff, which is reachable for a stale pull request whose change already landed through a duplicate. It now emits empty matrices and exits 0, which the `if: ... != '[]'` guards and the auto-merge job's job-count check already handle. grep is kept out of the pipeline so `|| true` tolerates only its no-match status, and the empty case is explicit rather than relying on `jq -R .` turning an empty string into [""]. --- .github/scripts/select-examples.sh | 21 +++++++++++++++++++-- .github/workflows/dependabot-automerge.yaml | 20 ++++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.github/scripts/select-examples.sh b/.github/scripts/select-examples.sh index f2590b4a..3085d2b1 100755 --- a/.github/scripts/select-examples.sh +++ b/.github/scripts/select-examples.sh @@ -51,8 +51,25 @@ if grep -qE '^(src/|layer/|Cargo\.toml$|Cargo\.lock$|\.github/workflows/examples exit 0 fi -# examples//... -> -names="$(grep -oE '^examples/[^/]+' <<<"$changed" | cut -d/ -f2 | sort -u | jq -R . | jq -sc .)" +# examples//... -> . grep exits 1 when nothing matches, which pipefail +# would turn into an unexplained failure of this script — so tolerate that one status, +# and only that one, by keeping grep out of the pipeline below. +example_paths="$(grep -oE '^examples/[^/]+' <<<"$changed" || true)" + +# Reachable with an empty diff: a stale pull request whose change already landed +# through a duplicate (#804 and #811 carry an identical update set), or a re-run after +# the commit merged. "Select nothing" is the documented contract here, not "fail" — +# the `if: ... != '[]'` guards in examples.yaml skip the test jobs, and the auto-merge +# workflow refuses a run with no successful test job. +if [[ -z "$example_paths" ]]; then + echo "No example changed: nothing to verify." + for kind in image zip stream; do + echo "$kind=[]" >>"$GITHUB_OUTPUT" + done + exit 0 +fi + +names="$(cut -d/ -f2 <<<"$example_paths" | sort -u | jq -R . | jq -sc .)" echo "Changed examples: $names" for kind in image zip stream; do diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index 17f36ed4..53dbc039 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -93,8 +93,24 @@ jobs: # example while never exercising the second. Require coverage for every # changed example, read at the verified commit. changed_examples=$(cut -d/ -f2 <<<"$files" | sort -u) - covered=$(gh api "repos/$REPO/contents/.github/example-matrix.json?ref=$HEAD_SHA" \ - -H 'Accept: application/vnd.github.raw' -q '[.[][].name] | unique | .[]' | sort -u) + + # Read the matrix from the default branch, not from $HEAD_SHA: a Dependabot + # branch cut before this file existed 404s at its own head, and gh's non-zero + # exit would abort the step with a bare "Not Found" instead of refusing + # deliberately like every other unresolvable condition here. The default + # branch is also the correct source of truth — this is repository config, the + # examples-only guard above means the pull request cannot have changed it, and + # a pull_request workflow runs the merge-ref copy anyway, so this is the matrix + # the run actually used. + if ! matrix_json=$(gh api "repos/$REPO/contents/.github/example-matrix.json" \ + -H 'Accept: application/vnd.github.raw'); then + echo "Could not read .github/example-matrix.json; refusing to merge." + exit 0 + fi + if ! covered=$(jq -r '[.[][].name] | unique | .[]' <<<"$matrix_json" | sort -u); then + echo "Could not parse .github/example-matrix.json; refusing to merge." + exit 0 + fi uncovered=$(comm -23 <(printf '%s\n' "$changed_examples") <(printf '%s\n' "$covered") || true) if [[ -n "$uncovered" ]]; then echo "PR #$PR_NUMBER changes examples with no build-and-boot coverage:" From 1d2aca2960f07f83c36ed9293f8fe1b2bb48deae Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 16:59:22 +0000 Subject: [PATCH 09/23] ci: make one-PR-per-example structural, and classify merge failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from the fifth review on #844. The per-directory grouping premise was wrong, and my earlier confirmation of it was bad evidence. #804 and #811 do show Dependabot splitting an identical update set per directory — but those PRs were produced with no dependabot.yml at all, so they describe ungrouped security updates, not what `directories` plus a group does. The search API finds 307,565 pull requests titled "Bump the group across N directories with M updates", including "across 17 directories with 100 updates". Grouping does span directories. That breaks two things this PR asserted. The blast-radius rationale inverts: a bad bump would block the security fixes for every example in the group rather than one. And auto-merge would essentially never fire, because the coverage gate refuses any pull request touching an example that example-matrix.json does not build and boot, and a cross-directory npm or pip pull request would nearly always include one. So the config is now one entry per example — 47 of them, plus github-actions — each scoped with `directories: ["/examples/", "/examples//**"]`. One pull request per example app is now a property of the config rather than an assumption about Dependabot's behavior, which is what the review asked for. The cost is a 579-line config file; the alternative was keeping eight short entries and an auto-merge workflow that never merges anything. Classify merge failures instead of going red on all of them. `gh pr merge` exits non-zero when the pull request is not mergeable, and under set -e that turned an expected outcome into a red run — on the one workflow whose colour signals whether the automation is healthy. The reachable case is the sibling race this PR exists to fix: several pull requests touch one lockfile, the first merge conflicts the rest, and GitHub has not necessarily recomputed mergeability yet. A moved head or a non-mergeable state now exits 0 with the reason; anything unexplained still fails loudly, so a real misconfiguration (squash merges disabled, a missing permission) is not swallowed. Four paths exercised against a stubbed gh. --- .github/dependabot.yml | 571 +++++++++++++++++--- .github/workflows/dependabot-automerge.yaml | 38 +- 2 files changed, 535 insertions(+), 74 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 897a0a08..3fce7216 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,139 +1,573 @@ # Dependabot configuration for the example applications. # -# The adapter itself (Cargo.toml at the repo root) is deliberately NOT managed here: -# it ships as the lambda-adapter binary, and its dependencies are reviewed by hand. +# The adapter itself (Cargo.toml at the repo root) is deliberately NOT managed here: it +# ships as the lambda-adapter binary, and its dependencies are reviewed by hand. # -# Why this file exists: without it, Dependabot opens one pull request per advisory -# per manifest. That produced 69 open PRs, eight of them against the single lockfile -# in examples/remix/remix-app, which then conflict with each other as soon as one -# lands. The `groups` blocks below batch each ecosystem's security fixes so an -# example app is updated by one PR instead of eight. +# Why this file exists: without it, Dependabot opens one pull request per advisory per +# manifest. That produced 69 open PRs, thirteen of them against the single lockfile in +# examples/remix/remix-app, which then conflict with each other as soon as one lands. +# Each entry below groups an example's security fixes into one pull request. # -# Grouping is per directory: Dependabot treats each (ecosystem, directory) pair as its -# own update and does not batch across directories unless `group-by: dependency-name` -# is set, which applies to version updates only. So this yields one pull request per -# example app per ecosystem, which is the intent — a broken bump then fails one -# example's verification instead of blocking every example's fixes at once. +# Why one entry per example rather than one glob entry per ecosystem: `directories` with +# a glob plus a group produces a single pull request spanning every matching directory — +# the "Bump the npm group across 17 directories with 100 updates" form, of which there +# are hundreds of thousands in the wild. That would be worse here on two counts. A bad +# bump in one example would block the security fixes for every other example in the +# group, and .github/workflows/dependabot-automerge.yaml refuses any pull request +# touching an example that .github/example-matrix.json does not build and boot — most of +# them — so a cross-directory pull request would essentially never auto-merge. One entry +# per example makes "one pull request per example app" a property of the config instead +# of an assumption about Dependabot's grouping behavior. # -# `open-pull-requests-limit: 0` disables *version* updates and leaves *security* -# updates on, which keeps the current behavior: Dependabot only opens PRs for -# advisories, not for every dependency that drifts behind. Security updates are -# explicitly exempt from this limit and do not count toward it. Raise it per ecosystem -# if you later want routine version bumps too. +# `open-pull-requests-limit: 0` disables *version* updates and leaves *security* updates +# on, which keeps the current behavior: pull requests for advisories only, not for every +# dependency that has drifted. Security updates are explicitly exempt from this limit and +# do not count toward it. +# +# `commit-message.prefix` is what keeps Commit Lint green: it runs on every pull request +# with no path filter, and Dependabot's default message has no conventional type. #799 is +# the evidence — commit "bump com.fasterxml.jackson.core:jackson-databind", check red. version: 2 updates: - - package-ecosystem: npm - directories: - - "/examples/**" + # bundler + - package-ecosystem: bundler + directories: ["/examples/sinatra", "/examples/sinatra/**"] schedule: interval: weekly open-pull-requests-limit: 0 commit-message: prefix: chore groups: - examples-npm: + sinatra-bundler: applies-to: security-updates - patterns: - - "*" + patterns: ["*"] - - package-ecosystem: pip - directories: - - "/examples/**" + # cargo + - package-ecosystem: cargo + directories: ["/examples/rust-actix-web-zip", "/examples/rust-actix-web-zip/**"] schedule: interval: weekly open-pull-requests-limit: 0 commit-message: prefix: chore groups: - examples-pip: + rust-actix-web-zip-cargo: applies-to: security-updates - patterns: - - "*" + patterns: ["*"] + - package-ecosystem: cargo + directories: ["/examples/rust-axum-zip", "/examples/rust-axum-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + rust-axum-zip-cargo: + applies-to: security-updates + patterns: ["*"] + # gomod + - package-ecosystem: gomod + directories: ["/examples/gin", "/examples/gin/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + gin-gomod: + applies-to: security-updates + patterns: ["*"] - package-ecosystem: gomod - directories: - - "/examples/**" + directories: ["/examples/gin-zip", "/examples/gin-zip/**"] schedule: interval: weekly open-pull-requests-limit: 0 commit-message: prefix: chore groups: - examples-gomod: + gin-zip-gomod: applies-to: security-updates - patterns: - - "*" + patterns: ["*"] + - package-ecosystem: gomod + directories: ["/examples/go-http-zip", "/examples/go-http-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + go-http-zip-gomod: + applies-to: security-updates + patterns: ["*"] + # maven - package-ecosystem: maven - directories: - - "/examples/**" + directories: ["/examples/javalin-zip", "/examples/javalin-zip/**"] schedule: interval: weekly open-pull-requests-limit: 0 commit-message: prefix: chore groups: - examples-maven: + javalin-zip-maven: applies-to: security-updates - patterns: - - "*" + patterns: ["*"] + - package-ecosystem: maven + directories: ["/examples/springboot", "/examples/springboot/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + springboot-maven: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: maven + directories: ["/examples/springboot-response-streaming-zip", "/examples/springboot-response-streaming-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + springboot-response-streaming-zip-maven: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: maven + directories: ["/examples/springboot-zip", "/examples/springboot-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + springboot-zip-maven: + applies-to: security-updates + patterns: ["*"] - - package-ecosystem: nuget - directories: - - "/examples/**" + # npm + - package-ecosystem: npm + directories: ["/examples/bun-graphql-streaming-zip", "/examples/bun-graphql-streaming-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + bun-graphql-streaming-zip-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/bun-graphql-zip", "/examples/bun-graphql-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + bun-graphql-zip-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/datadog", "/examples/datadog/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + datadog-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/datadog-zip", "/examples/datadog-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + datadog-zip-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/expressjs", "/examples/expressjs/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + expressjs-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/expressjs-zip", "/examples/expressjs-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + expressjs-zip-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/nextjs", "/examples/nextjs/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + nextjs-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/nextjs-response-streaming", "/examples/nextjs-response-streaming/**"] schedule: interval: weekly open-pull-requests-limit: 0 commit-message: prefix: chore groups: - examples-nuget: + nextjs-response-streaming-npm: applies-to: security-updates - patterns: - - "*" + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/nextjs-zip", "/examples/nextjs-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + nextjs-zip-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/remix", "/examples/remix/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + remix-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/remix-zip", "/examples/remix-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + remix-zip-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/sls", "/examples/sls/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + sls-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/sqs-expressjs", "/examples/sqs-expressjs/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + sqs-expressjs-npm: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/sveltekit-ssr-zip", "/examples/sveltekit-ssr-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + sveltekit-ssr-zip-npm: + applies-to: security-updates + patterns: ["*"] - - package-ecosystem: cargo - directories: - - "/examples/**" + # nuget + - package-ecosystem: nuget + directories: ["/examples/aspnet-mvc", "/examples/aspnet-mvc/**"] schedule: interval: weekly open-pull-requests-limit: 0 commit-message: prefix: chore groups: - examples-cargo: + aspnet-mvc-nuget: applies-to: security-updates - patterns: - - "*" + patterns: ["*"] + - package-ecosystem: nuget + directories: ["/examples/aspnet-mvc-zip", "/examples/aspnet-mvc-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + aspnet-mvc-zip-nuget: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: nuget + directories: ["/examples/aspnet-webapi-zip", "/examples/aspnet-webapi-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + aspnet-webapi-zip-nuget: + applies-to: security-updates + patterns: ["*"] - # examples/sinatra/app/src/Gemfile. Easy to miss, and the cost of missing it is not - # just ungrouped PRs: without `prefix: chore` Dependabot writes "bump rack from ...", - # which has no conventional type, and Commit Lint runs on every pull request with no - # path filter. #799 is the evidence — its commit message is - # "bump com.fasterxml.jackson.core:jackson-databind" and its Commit Lint check is red. - - package-ecosystem: bundler - directories: - - "/examples/**" + # pip + - package-ecosystem: pip + directories: ["/examples/bedrock-agent-fastapi", "/examples/bedrock-agent-fastapi/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + bedrock-agent-fastapi-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/bedrock-agent-fastapi-zip", "/examples/bedrock-agent-fastapi-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + bedrock-agent-fastapi-zip-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/datadog", "/examples/datadog/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + datadog-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi", "/examples/fastapi/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastapi-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-backend-only-response-streaming", "/examples/fastapi-backend-only-response-streaming/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastapi-backend-only-response-streaming-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-background-tasks", "/examples/fastapi-background-tasks/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastapi-background-tasks-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-response-streaming", "/examples/fastapi-response-streaming/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastapi-response-streaming-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-response-streaming-lmi", "/examples/fastapi-response-streaming-lmi/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastapi-response-streaming-lmi-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-response-streaming-zip", "/examples/fastapi-response-streaming-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastapi-response-streaming-zip-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-snapstart", "/examples/fastapi-snapstart/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastapi-snapstart-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-snapstart-zip", "/examples/fastapi-snapstart-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastapi-snapstart-zip-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-zip", "/examples/fastapi-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastapi-zip-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fasthtml", "/examples/fasthtml/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fasthtml-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fasthtml-response-streaming", "/examples/fasthtml-response-streaming/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fasthtml-response-streaming-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fasthtml-response-streaming-zip", "/examples/fasthtml-response-streaming-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fasthtml-response-streaming-zip-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fasthtml-zip", "/examples/fasthtml-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fasthtml-zip-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastmcp", "/examples/fastmcp/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastmcp-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastmcp-zip", "/examples/fastmcp-zip/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + fastmcp-zip-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/flask", "/examples/flask/**"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + flask-pip: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/flask-zip", "/examples/flask-zip/**"] schedule: interval: weekly open-pull-requests-limit: 0 commit-message: prefix: chore groups: - examples-bundler: + flask-zip-pip: applies-to: security-updates - patterns: - - "*" + patterns: ["*"] # Not an example, and listed for the naming alone. Every workflow here pins actions # (actions/checkout@v4, Swatinem/rust-cache@v2, orhun/git-cliff-action@v4), so an - # actions advisory would open a PR titled "bump actions/... from X to Y" — no + # actions advisory would open a pull request titled "bump actions/... from X to Y" — no # conventional type, red Commit Lint, manual amend. With the limit at 0 this adds no - # pull requests; it only names the ones an advisory would produce anyway. There are - # no open actions alerts today, so this is pre-emptive. + # pull requests; it only names the ones an advisory would produce anyway. There are no + # open actions alerts today, so this is pre-emptive. - package-ecosystem: github-actions - directories: - - "/" + directories: ["/"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -142,5 +576,4 @@ updates: groups: github-actions: applies-to: security-updates - patterns: - - "*" + patterns: ["*"] diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index 53dbc039..6bff3974 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -156,8 +156,36 @@ jobs: fi echo "PR #$PR_NUMBER is example-only, fully covered, and passed $verified verification job(s)." - # --match-head-commit closes the remaining window: if the branch moves - # between the check above and this call, the merge is refused rather than - # applied to an unverified commit. - gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch \ - --match-head-commit "$HEAD_SHA" + + # --match-head-commit closes the remaining window: if the branch moves between + # the check above and this call, the API rejects the merge rather than applying + # it to an unverified commit. + # + # A rejection exits non-zero, which would turn this run red like a real + # failure. The common cause is benign and expected: sibling pull requests + # touching one lockfile finish minutes apart, the first merge conflicts the + # rest, and GitHub has not necessarily recomputed mergeability by the time we + # get here. Distinguish that from a genuine problem — squash merges disabled, + # a missing permission — so this workflow's red/green state still means + # something. + if gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch \ + --match-head-commit "$HEAD_SHA"; then + exit 0 + fi + + head_after=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid -q .headRefOid || echo unknown) + state=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json mergeStateStatus -q .mergeStateStatus || echo UNKNOWN) + if [[ "$head_after" != "$HEAD_SHA" ]]; then + echo "Merge rejected: head moved to $head_after. Leaving PR #$PR_NUMBER alone." + exit 0 + fi + case "$state" in + DIRTY | BLOCKED | BEHIND | DRAFT | UNKNOWN) + echo "Merge rejected: PR #$PR_NUMBER is not mergeable (mergeStateStatus=$state)." + echo "Most likely a sibling update landed first. Leaving it alone." + exit 0 + ;; + esac + echo "Unexpected merge failure for PR #$PR_NUMBER (mergeStateStatus=$state)." + echo "Nothing explains it, so failing loudly rather than hiding it." + exit 1 From 3c1ba6304113e53de7c44076fe1c580e4f1b4dfa Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 17:42:00 +0000 Subject: [PATCH 10/23] ci: re-evaluate auto-merge on a schedule, and stop swallowing jq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the sixth review on #844. Give the merge gate a second entry point. It was evaluated exactly once, when a Verify Examples run completed, so a pull request whose other checks were still running at that instant was skipped and never reconsidered — nothing else re-triggers the workflow, and Dependabot pushes a branch only when it rebases or recreates it, so an idle pull request could wait indefinitely. The review's point that this PR makes the race more likely is right: with the selector, Verify Examples runs build-layer plus one example instead of eighteen, so it stops being reliably the slowest check (measured on #842: Commit Lint 18s, Verify Examples 3m50s at eighteen jobs). The decision now lives in .github/scripts/dependabot-automerge.sh, driven by both a completed run and an hourly sweep, and it resolves the run itself from the pull request's current head rather than trusting an event payload — so both paths behave identically and the "head moved" special case disappears. Skips are recorded in the job summary, so a stalled pull request is visible rather than silent. The sweep pre-filters the queue in one request (23 of the 69 open pull requests are candidates today) to keep an hourly job off the token's rate limit. Coverage proof now comes from the run's own job names rather than only from the matrix file, which also closes a gap nobody raised: a run predating a matrix change could otherwise be credited with verifying an example it never launched. The matrix is still consulted, to tell "no entry, add one" apart from "entry exists, not run". Stop emit_all swallowing jq's exit status. `echo "$kind=$(jq ...)"` returns echo's 0 even when jq dies, so a missing or malformed example-matrix.json wrote `image=` and reported success — and an empty value is worse than a failure, because `!= '[]'` is true for it, so the test jobs ran and died in fromJSON('') pointing at nothing. Reproduced: three jq errors, exit 0, three empty outputs. Now exit 2 with jq's own diagnostic. Add an entry for the adapter's own Cargo.toml, for the commit message only. One correction to the review: this is pre-emptive, not live — all six open rust alerts are in examples/rust-actix-web-zip and examples/rust-axum-zip, which the per-example entries already cover, and none are against the root manifest. Fifteen scenarios exercised locally: the selector's four selection paths, and the merge script's eleven guard paths from happy-path merge through each refusal to the one case that still fails loudly. --- .github/dependabot.yml | 22 +- .github/scripts/dependabot-automerge.sh | 149 +++++++++++++ .github/scripts/select-examples.sh | 10 +- .github/workflows/dependabot-automerge.yaml | 230 ++++++-------------- 4 files changed, 249 insertions(+), 162 deletions(-) create mode 100755 .github/scripts/dependabot-automerge.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3fce7216..a4cbd97f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,8 @@ # Dependabot configuration for the example applications. # -# The adapter itself (Cargo.toml at the repo root) is deliberately NOT managed here: it -# ships as the lambda-adapter binary, and its dependencies are reviewed by hand. +# The adapter itself (Cargo.toml at the repo root) has an entry for its commit message +# only. Version updates stay off and the auto-merge workflow refuses anything outside +# examples/, so its dependencies are still reviewed by hand. # # Why this file exists: without it, Dependabot opens one pull request per advisory per # manifest. That produced 69 open PRs, thirteen of them against the single lockfile in @@ -560,6 +561,23 @@ updates: applies-to: security-updates patterns: ["*"] + # cargo (the adapter itself, not an example). Listed for the commit message only: the + # limit keeps version updates off, and dependabot-automerge.sh refuses anything + # touching files outside examples/, so an advisory here still gets a hand review — it + # just arrives with a header Commit Lint accepts. Pre-emptive: today's six open rust + # alerts are all in examples/rust-*-zip, which the entries above cover. + - package-ecosystem: cargo + directories: ["/"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + adapter-cargo: + applies-to: security-updates + patterns: ["*"] + # Not an example, and listed for the naming alone. Every workflow here pins actions # (actions/checkout@v4, Swatinem/rust-cache@v2, orhun/git-cliff-action@v4), so an # actions advisory would open a pull request titled "bump actions/... from X to Y" — no diff --git a/.github/scripts/dependabot-automerge.sh b/.github/scripts/dependabot-automerge.sh new file mode 100755 index 00000000..414691c9 --- /dev/null +++ b/.github/scripts/dependabot-automerge.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Merges one Dependabot pull request, if it is an example-only update that Verify +# Examples has verified at the pull request's current head. +# +# Usage: REPO= dependabot-automerge.sh +# +# Two triggers call this (see ../workflows/dependabot-automerge.yaml): a completed +# Verify Examples run, and an hourly sweep. The sweep exists because the checks on a +# pull request settle in arbitrary order — this gate used to be evaluated exactly once, +# when Verify Examples finished, so a pull request whose other checks were still running +# at that instant was skipped and never looked at again. Nothing else would have +# re-triggered it: Dependabot only pushes a branch when it rebases or recreates it, so +# an idle pull request could wait indefinitely. +# +# The run is resolved here rather than taken from an event payload, so both triggers +# behave identically and the verified commit is always the head that would be merged. +# +# Every not-yet or unresolvable condition exits 0 with a reason, and records it in the +# job summary so a skipped merge is visible instead of buried in a log. Refusing is the +# safe outcome; a red run here would be indistinguishable from a real fault. +set -euo pipefail + +PR="${1:?usage: dependabot-automerge.sh }" +: "${REPO:?REPO must be set}" + +# Joins a multi-line list onto one line for a message, without a trailing separator. +join_list() { + tr '\n' ' ' <<<"$1" | sed 's/ *$//' +} + +skip() { + echo "PR #$PR: $*" + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + echo "- **PR #$PR not merged** — $*" >>"$GITHUB_STEP_SUMMARY" + fi + exit 0 +} + +author=$(gh pr view "$PR" --repo "$REPO" --json author -q .author.login) +if [[ "$author" != "app/dependabot" && "$author" != "dependabot[bot]" ]]; then + skip "authored by $author, not Dependabot." +fi + +head_sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid) + +# A run for a superseded commit proves nothing about what would be merged, and +# Dependabot force-pushes these branches whenever it rebases. +run_id=$(gh api \ + "repos/$REPO/actions/workflows/examples.yaml/runs?head_sha=$head_sha&status=success&per_page=1" \ + -q '.workflow_runs[0].id // empty') +if [[ -z "$run_id" ]]; then + skip "no successful Verify Examples run for $head_sha (yet)." +fi + +# Verify Examples is not the only check. Commit Lint runs on every pull request with no +# path filter and does go red on Dependabot pull requests (#799), and any check added +# later would otherwise be ignored here too. Anything not green — including still +# running — means leave it alone; the sweep will look again. +# +# This workflow's own run is excluded defensively: workflow_run runs do not appear in a +# pull request's check rollup today, but if that changed, its in-progress state would +# deadlock every merge. +not_green=$(gh pr view "$PR" --repo "$REPO" --json statusCheckRollup -q ' + .statusCheckRollup[] + | select((.workflowName // "") != "Dependabot Auto-merge") + | select([((.conclusion // .state // "PENDING") | ascii_upcase)] + - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0) + | ((.name // .context) + " = " + (.conclusion // .state // "PENDING"))') +if [[ -n "$not_green" ]]; then + skip "checks are not all green: $(join_list "$not_green")" +fi + +# Fail closed: an empty file list must never read as "nothing outside examples/". The +# API call is kept out of the pipeline so only grep's no-match status is tolerated. +files=$(gh api --paginate "repos/$REPO/pulls/$PR/files" -q '.[].filename') +if [[ -z "$files" ]]; then + skip "could not list its files." +fi + +# Scope by what the pull request changes rather than by its branch name: grouped updates +# do not reliably encode the directory in the ref. Examples are demo apps, so a bad bump +# costs a broken sample; the adapter's own dependencies, the workflows and the templates +# stay manual. +outside=$(grep -v '^examples/' <<<"$files" || true) +if [[ -n "$outside" ]]; then + skip "changes files outside examples/: $(join_list "$outside")" +fi + +changed_examples=$(cut -d/ -f2 <<<"$files" | sort -u) + +# Refuse to merge an example that was not built and booted. Proof comes from the run's +# own job names, so a run that predates a matrix change cannot be credited with +# verifying an example it never launched. If GitHub ever changes how matrix jobs are +# named, this stops finding matches and merges stop — fail-closed, and visible in the +# summary rather than silent. +verified_examples=$(gh api "repos/$REPO/actions/runs/$run_id/jobs" --paginate \ + -q '.jobs[] | select(.name | startswith("test-")) | select(.conclusion == "success") | .name' \ + | sed -E 's/^test-[a-z]+ \(([^,)]+).*/\1/' | sort -u) +unverified=$(comm -23 <(printf '%s\n' "$changed_examples") <(printf '%s\n' "$verified_examples") || true) +if [[ -n "$unverified" ]]; then + # Distinguish "not covered by the matrix at all" from "covered but not run", because + # the fixes differ: add a matrix entry, versus re-run the verification. + if ! matrix_json=$(gh api "repos/$REPO/contents/.github/example-matrix.json" \ + -H 'Accept: application/vnd.github.raw'); then + skip "could not read .github/example-matrix.json." + fi + if ! covered=$(jq -r '[.[][].name] | unique | .[]' <<<"$matrix_json" | sort -u); then + skip "could not parse .github/example-matrix.json." + fi + uncovered=$(comm -23 <(printf '%s\n' "$unverified") <(printf '%s\n' "$covered") || true) + if [[ -n "$uncovered" ]]; then + skip "no build-and-boot coverage for: $(join_list "$uncovered") — add them to .github/example-matrix.json, or review by hand." + fi + skip "these examples are in the matrix but were not verified by run $run_id: $(join_list "$unverified")" +fi + +echo "PR #$PR is example-only and verified at $head_sha by run $run_id. Merging." + +# --match-head-commit closes the remaining window: if the branch moves between the +# lookups above and this call, the API rejects the merge rather than applying it to an +# unverified commit. +# +# A rejection exits non-zero, which would turn this run red like a real failure. The +# common cause is benign: sibling pull requests touching one lockfile finish minutes +# apart, the first merge conflicts the rest, and GitHub has not necessarily recomputed +# mergeability yet. Distinguish that from a genuine problem — squash merges disabled, a +# missing permission — so this workflow's red/green state still means something. +if gh pr merge "$PR" --repo "$REPO" --squash --delete-branch \ + --match-head-commit "$head_sha"; then + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + echo "- **PR #$PR merged** — verified at \`${head_sha:0:8}\` by run $run_id" >>"$GITHUB_STEP_SUMMARY" + fi + exit 0 +fi + +head_after=$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid || echo unknown) +state=$(gh pr view "$PR" --repo "$REPO" --json mergeStateStatus -q .mergeStateStatus || echo UNKNOWN) +if [[ "$head_after" != "$head_sha" ]]; then + skip "merge rejected, head moved to $head_after." +fi +case "$state" in + DIRTY | BLOCKED | BEHIND | DRAFT | UNKNOWN) + skip "merge rejected, not mergeable (mergeStateStatus=$state) — most likely a sibling update landed first." + ;; +esac +echo "PR #$PR: unexpected merge failure (mergeStateStatus=$state)." +echo "Nothing explains it, so failing loudly rather than hiding it." +exit 1 diff --git a/.github/scripts/select-examples.sh b/.github/scripts/select-examples.sh index 3085d2b1..f9d287b3 100755 --- a/.github/scripts/select-examples.sh +++ b/.github/scripts/select-examples.sh @@ -17,10 +17,16 @@ set -euo pipefail MATRIX="$(dirname "$0")/../example-matrix.json" +# Assign before echoing, so a jq failure is the command's status rather than an +# argument to echo: `echo "x=$(jq ...)"` returns echo's 0 even when jq dies, and +# set -e never fires. That wrote `image=` to $GITHUB_OUTPUT and reported success — and +# an empty value is worse than a failure, because `!= '[]'` is true for it, so the test +# jobs would run and die in fromJSON('') with an error unrelated to the real cause. emit_all() { - local kind + local kind matrix for kind in image zip stream; do - echo "$kind=$(jq -c ".$kind" "$MATRIX")" >>"$GITHUB_OUTPUT" + matrix="$(jq -c ".$kind" "$MATRIX")" + echo "$kind=$matrix" >>"$GITHUB_OUTPUT" done } diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index 6bff3974..f45a35f9 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -1,14 +1,23 @@ name: Dependabot Auto-merge # Merges Dependabot pull requests that only touch example applications, once Verify -# Examples has actually verified them. +# Examples has actually verified them. The decision lives in +# .github/scripts/dependabot-automerge.sh; this file only decides which pull requests to +# offer it. # -# Keyed off the completed workflow run rather than `gh pr merge --auto`: auto-merge is -# gated on the repository's *required* status checks, and Verify Examples is -# path-filtered to examples/**, so requiring its result would never report on a -# source-only pull request and would block it forever. The completed run is also tied -# to a specific commit, which is what lets the merge below be pinned to the commit that -# was verified. +# Two triggers, deliberately: +# +# workflow_run — the fast path. A pull request that is already green elsewhere merges +# within seconds of its verification finishing. +# schedule — the catch-all. Checks on a pull request settle in arbitrary order, so +# Verify Examples can finish while Commit Lint or CodeQL is still running. With only +# the first trigger, such a pull request was skipped and never reconsidered, because +# nothing else re-triggers this workflow: Dependabot pushes a branch only when it +# rebases or recreates it, so an idle pull request could wait indefinitely. +# +# Not `gh pr merge --auto`: auto-merge is gated on the repository's *required* status +# checks, and Verify Examples is path-filtered to examples/**, so requiring its result +# would never report on a source-only pull request and would block it forever. # # Requires no branch protection and no repository settings. on: @@ -16,176 +25,81 @@ on: workflows: ["Verify Examples"] types: - completed + schedule: + # Hourly, off the hour to avoid the busiest minute. + - cron: "17 * * * *" + workflow_dispatch: + +# One at a time: the two triggers can overlap, and two runs racing to merge the same +# pull request would just make one of them report a conflict it did not cause. +concurrency: + group: dependabot-automerge + cancel-in-progress: false permissions: contents: write pull-requests: write - # Required to list the triggering run's jobs. Declaring a permissions block sets - # every scope not named here to none, so without this the Actions API returns 403 - # and the step aborts on every Dependabot pull request. Same reason - # commitlint-comment.yaml declares it. + # Required to list a run's jobs and to look a run up by head SHA. Declaring a + # permissions block sets every scope not named here to none, so without this the + # Actions API returns 403. Same reason commitlint-comment.yaml declares it. actions: read jobs: merge: - # Only a green pull request run can merge anything. A push run has no pull request - # to merge, and a failed run is the whole point of the gate. - if: >- - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-24.04 steps: - - name: Merge if this is an example-only Dependabot update that was verified + - uses: actions/checkout@v4 + with: + # Pinned to the default branch on purpose. This job holds a write token, so it + # must never run a script from a pull request's head — the ref is not derived + # from the event under any trigger. + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Merge eligible Dependabot pull requests env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} - RUN_ID: ${{ github.event.workflow_run.id }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + # Empty for schedule and manual runs, which sweep every open Dependabot PR. + TRIGGERED_PR: ${{ github.event.workflow_run.pull_requests[0].number }} run: | set -euo pipefail - if [[ -z "${PR_NUMBER:-}" ]]; then - echo "Run is not associated with a pull request; nothing to merge." + if [[ -n "${TRIGGERED_PR:-}" ]]; then + prs="$TRIGGERED_PR" + elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then + # A run with no associated pull request: a push to main, or a fork. + echo "Triggering run has no pull request; nothing to do." exit 0 + else + # One request covers the whole queue, so the sweep can drop the pull + # requests that obviously cannot merge — conflicted, or a check red or + # pending — before spending ~6 API calls each on them in the script. Only + # CONFLICTING is excluded on mergeability, not "anything but MERGEABLE": + # GitHub reports UNKNOWN while it recomputes, and dropping those could skip + # a pull request forever. With + # ~70 open Dependabot pull requests that is the difference between a cheap + # hourly sweep and one that eats the token's hourly budget. + prs=$(gh pr list --repo "$REPO" --author app/dependabot --state open \ + --limit 200 --json number,mergeable,statusCheckRollup -q ' + .[] + | select(.mergeable != "CONFLICTING") + | select([.statusCheckRollup[]? + | select([((.conclusion // .state // "PENDING") | ascii_upcase)] + - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0)] + | length == 0) + | .number') fi - author=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json author -q .author.login) - if [[ "$author" != "app/dependabot" && "$author" != "dependabot[bot]" ]]; then - echo "PR #$PR_NUMBER is authored by $author, not Dependabot. Skipping." + if [[ -z "$prs" ]]; then + echo "No open Dependabot pull requests." exit 0 fi - # Everything below reasons about $HEAD_SHA, the commit this run verified — - # never "whatever the head is now". Dependabot force-pushes its branches when - # it rebases or recreates them, so the head can move while the run is - # finishing, and merging the new head would merge something unverified. - head_now=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid -q .headRefOid) - if [[ "$head_now" != "$HEAD_SHA" ]]; then - echo "PR #$PR_NUMBER moved from $HEAD_SHA to $head_now since it was verified." - echo "That push starts its own Verify Examples run; leaving the merge to it." - exit 0 - fi - - # Fail closed: an empty file list must never read as "nothing outside - # examples/". Keep the API call out of the pipeline so that only grep's - # no-match status is tolerated, not a failed or rate-limited request. - files=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" -q '.[].filename') - if [[ -z "$files" ]]; then - echo "Could not list the files for PR #$PR_NUMBER; refusing to merge." - exit 0 - fi - - # Scope by what the pull request changes rather than by its branch name: - # grouped updates do not reliably encode the directory in the ref. Examples - # are demo apps, so a bad bump costs a broken sample; the adapter's own - # dependencies, the workflows, and the templates stay manual. - outside=$(grep -v '^examples/' <<<"$files" || true) - if [[ -n "$outside" ]]; then - echo "PR #$PR_NUMBER changes files outside examples/:" - echo "$outside" | sed 's/^/ /' - exit 0 - fi - - # Refuse to merge an example that is not built and booted. select-examples.sh - # silently drops changed examples with no matrix entry, so a job count alone - # would pass a pull request that touches one covered and one uncovered - # example while never exercising the second. Require coverage for every - # changed example, read at the verified commit. - changed_examples=$(cut -d/ -f2 <<<"$files" | sort -u) - - # Read the matrix from the default branch, not from $HEAD_SHA: a Dependabot - # branch cut before this file existed 404s at its own head, and gh's non-zero - # exit would abort the step with a bare "Not Found" instead of refusing - # deliberately like every other unresolvable condition here. The default - # branch is also the correct source of truth — this is repository config, the - # examples-only guard above means the pull request cannot have changed it, and - # a pull_request workflow runs the merge-ref copy anyway, so this is the matrix - # the run actually used. - if ! matrix_json=$(gh api "repos/$REPO/contents/.github/example-matrix.json" \ - -H 'Accept: application/vnd.github.raw'); then - echo "Could not read .github/example-matrix.json; refusing to merge." - exit 0 - fi - if ! covered=$(jq -r '[.[][].name] | unique | .[]' <<<"$matrix_json" | sort -u); then - echo "Could not parse .github/example-matrix.json; refusing to merge." - exit 0 - fi - uncovered=$(comm -23 <(printf '%s\n' "$changed_examples") <(printf '%s\n' "$covered") || true) - if [[ -n "$uncovered" ]]; then - echo "PR #$PR_NUMBER changes examples with no build-and-boot coverage:" - echo "$uncovered" | sed 's/^/ /' - echo "Add them to .github/example-matrix.json, or review this by hand." - exit 0 - fi - - # Verify Examples is not the only check on the pull request. Commit Lint runs - # on every pull request with no path filter and does go red on Dependabot - # PRs (#799), and any check added later would be ignored here too. Anything - # not green — including still running, since this workflow cannot wait — means - # leave it alone; Dependabot rebases these branches often, and any later - # Verify Examples run re-evaluates the pull request. - # - # This workflow's own run is excluded defensively. workflow_run runs do not - # currently appear in a pull request's check rollup, but if that changed, its - # in-progress state would deadlock every merge. - not_green=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json statusCheckRollup -q ' - .statusCheckRollup[] - | select((.workflowName // "") != "Dependabot Auto-merge") - | select([((.conclusion // .state // "PENDING") | ascii_upcase)] - - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0) - | ((.name // .context) + " = " + (.conclusion // .state // "PENDING"))') - if [[ -n "$not_green" ]]; then - echo "PR #$PR_NUMBER has checks that are not green; refusing to merge:" - echo "$not_green" | sed 's/^/ /' - exit 0 - fi - - # Covered examples are only actually verified if their jobs ran. Count lines - # rather than asking jq for a length: --paginate applies -q per page, so a - # run spanning two pages would yield one count per line ("18\n4"), and - # `[[ "18\n4" -eq 0 ]]` is an arithmetic syntax error that evaluates false — - # skipping the refusal below and merging. Only the job total keeps this - # single-page today, which is not a property worth depending on. - verified=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs" --paginate \ - -q '.jobs[] | select(.name | startswith("test-")) | select(.conclusion == "success") | .name' \ - | wc -l) - if [[ "$verified" -eq 0 ]]; then - echo "No test-* job succeeded in run $RUN_ID; refusing to merge." - exit 0 - fi - - echo "PR #$PR_NUMBER is example-only, fully covered, and passed $verified verification job(s)." - - # --match-head-commit closes the remaining window: if the branch moves between - # the check above and this call, the API rejects the merge rather than applying - # it to an unverified commit. - # - # A rejection exits non-zero, which would turn this run red like a real - # failure. The common cause is benign and expected: sibling pull requests - # touching one lockfile finish minutes apart, the first merge conflicts the - # rest, and GitHub has not necessarily recomputed mergeability by the time we - # get here. Distinguish that from a genuine problem — squash merges disabled, - # a missing permission — so this workflow's red/green state still means - # something. - if gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch \ - --match-head-commit "$HEAD_SHA"; then - exit 0 - fi - - head_after=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid -q .headRefOid || echo unknown) - state=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json mergeStateStatus -q .mergeStateStatus || echo UNKNOWN) - if [[ "$head_after" != "$HEAD_SHA" ]]; then - echo "Merge rejected: head moved to $head_after. Leaving PR #$PR_NUMBER alone." - exit 0 - fi - case "$state" in - DIRTY | BLOCKED | BEHIND | DRAFT | UNKNOWN) - echo "Merge rejected: PR #$PR_NUMBER is not mergeable (mergeStateStatus=$state)." - echo "Most likely a sibling update landed first. Leaving it alone." - exit 0 - ;; - esac - echo "Unexpected merge failure for PR #$PR_NUMBER (mergeStateStatus=$state)." - echo "Nothing explains it, so failing loudly rather than hiding it." - exit 1 + # One bad pull request must not stop the sweep from considering the rest, so + # collect the status instead of letting set -e abort the loop. + rc=0 + for pr in $prs; do + .github/scripts/dependabot-automerge.sh "$pr" || rc=1 + done + exit $rc From 3a3940f89bdb77923fea4c8c564c55666ed96823 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 17:58:32 +0000 Subject: [PATCH 11/23] ci: spell out Dependabot's directories, and three review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config was rejected: "Update configs must have a unique combination of 'package-ecosystem', 'directory', and 'target-branch'. Dependabot cannot determine if 'npm' has overlapping directories." Globs are the problem — with several entries per ecosystem, Dependabot will not merely assume that /examples/remix/** and /examples/remix-zip/** are disjoint, it refuses the file. So each entry now lists the manifest directories inside its example, derived from `git ls-files examples`: 53 directories across 47 example entries. Three examples have more than one manifest and keep grouping across them, which is the point — datadog alone has five. Key the concurrency group per pull request. Only one run per group may be pending and a new arrival cancels the pending one, so a single group meant a burst of Verify Examples runs finishing together — the normal case, since Dependabot opens security pull requests in batches — would cancel each other's queued fast-path runs until only the last survived. The overlap this allows between a sweep and a fast path on one pull request is already handled by --match-head-commit and the mergeStateStatus branch, which turn the loser into a skip rather than a red run. Handle a failed API call the way the header says the script handles everything else. Five gh lookups would abort the script bare under set -e, with no summary line and rc=1 propagating out of the sweep loop — the opposite of the care the merge block takes to keep this workflow's red/green meaningful. They now skip with a reason. Author, head SHA and the check rollup also come from one `gh pr view` call rather than three, which both simplifies that handling and cuts the sweep's per-pull-request cost. Read the matrix from disk rather than the API. The workflow already checks out the default branch, and the API call read that same content, so it was a network round trip and two failure branches for nothing. Moving the coverage check ahead of the run and job lookups matters more than it looks: an example with no matrix entry can never merge, and most examples have none, so those pull requests now cost two API calls per sweep instead of four, forever. Fifteen guard paths exercised, including the four new API-failure paths: each exits 0 with a summary line, and the only loud failure is still a merge that fails for no discoverable reason. --- .github/dependabot.yml | 130 +++++++++++--------- .github/scripts/dependabot-automerge.sh | 99 +++++++++------ .github/workflows/dependabot-automerge.yaml | 15 ++- 3 files changed, 145 insertions(+), 99 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a4cbd97f..a2f4e930 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,7 @@ # Why this file exists: without it, Dependabot opens one pull request per advisory per # manifest. That produced 69 open PRs, thirteen of them against the single lockfile in # examples/remix/remix-app, which then conflict with each other as soon as one lands. -# Each entry below groups an example's security fixes into one pull request. +# Each entry below groups one example's security fixes into one pull request. # # Why one entry per example rather than one glob entry per ecosystem: `directories` with # a glob plus a group produces a single pull request spanning every matching directory — @@ -20,6 +20,13 @@ # per example makes "one pull request per example app" a property of the config instead # of an assumption about Dependabot's grouping behavior. # +# Why the directories are spelled out rather than globbed: Dependabot rejects a config +# whose entries for one ecosystem it cannot prove have non-overlapping directories, and +# it cannot prove that for globs — "Dependabot cannot determine if 'npm' has overlapping +# directories". Each entry therefore lists the manifest directories inside its example. +# `git ls-files examples` is how this list was derived; a new example needs an entry +# here, and Verify Examples does not check for that. +# # `open-pull-requests-limit: 0` disables *version* updates and leaves *security* updates # on, which keeps the current behavior: pull requests for advisories only, not for every # dependency that has drifted. Security updates are explicitly exempt from this limit and @@ -32,7 +39,7 @@ version: 2 updates: # bundler - package-ecosystem: bundler - directories: ["/examples/sinatra", "/examples/sinatra/**"] + directories: ["/examples/sinatra/app/src"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -45,7 +52,7 @@ updates: # cargo - package-ecosystem: cargo - directories: ["/examples/rust-actix-web-zip", "/examples/rust-actix-web-zip/**"] + directories: ["/examples/rust-actix-web-zip/rust_app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -56,7 +63,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: cargo - directories: ["/examples/rust-axum-zip", "/examples/rust-axum-zip/**"] + directories: ["/examples/rust-axum-zip/rust_app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -69,7 +76,7 @@ updates: # gomod - package-ecosystem: gomod - directories: ["/examples/gin", "/examples/gin/**"] + directories: ["/examples/gin/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -80,7 +87,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: gomod - directories: ["/examples/gin-zip", "/examples/gin-zip/**"] + directories: ["/examples/gin-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -91,7 +98,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: gomod - directories: ["/examples/go-http-zip", "/examples/go-http-zip/**"] + directories: ["/examples/go-http-zip"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -104,7 +111,7 @@ updates: # maven - package-ecosystem: maven - directories: ["/examples/javalin-zip", "/examples/javalin-zip/**"] + directories: ["/examples/javalin-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -115,7 +122,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: maven - directories: ["/examples/springboot", "/examples/springboot/**"] + directories: ["/examples/springboot/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -126,7 +133,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: maven - directories: ["/examples/springboot-response-streaming-zip", "/examples/springboot-response-streaming-zip/**"] + directories: ["/examples/springboot-response-streaming-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -137,7 +144,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: maven - directories: ["/examples/springboot-zip", "/examples/springboot-zip/**"] + directories: ["/examples/springboot-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -150,7 +157,7 @@ updates: # npm - package-ecosystem: npm - directories: ["/examples/bun-graphql-streaming-zip", "/examples/bun-graphql-streaming-zip/**"] + directories: ["/examples/bun-graphql-streaming-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -161,7 +168,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/bun-graphql-zip", "/examples/bun-graphql-zip/**"] + directories: ["/examples/bun-graphql-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -172,7 +179,12 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/datadog", "/examples/datadog/**"] + directories: + - "/examples/datadog/expressjs-streaming/cdk" + - "/examples/datadog/expressjs-streaming/lambda-asset/src" + - "/examples/datadog/expressjs/cdk" + - "/examples/datadog/expressjs/lambda-asset/src" + - "/examples/datadog/flask/cdk" schedule: interval: weekly open-pull-requests-limit: 0 @@ -183,7 +195,9 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/datadog-zip", "/examples/datadog-zip/**"] + directories: + - "/examples/datadog-zip/expressjs/cdk" + - "/examples/datadog-zip/expressjs/lambda-asset/src" schedule: interval: weekly open-pull-requests-limit: 0 @@ -194,7 +208,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/expressjs", "/examples/expressjs/**"] + directories: ["/examples/expressjs/app/src"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -205,7 +219,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/expressjs-zip", "/examples/expressjs-zip/**"] + directories: ["/examples/expressjs-zip/hello-world"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -216,7 +230,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/nextjs", "/examples/nextjs/**"] + directories: ["/examples/nextjs/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -227,7 +241,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/nextjs-response-streaming", "/examples/nextjs-response-streaming/**"] + directories: ["/examples/nextjs-response-streaming"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -238,7 +252,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/nextjs-zip", "/examples/nextjs-zip/**"] + directories: ["/examples/nextjs-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -249,7 +263,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/remix", "/examples/remix/**"] + directories: ["/examples/remix/remix-app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -260,7 +274,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/remix-zip", "/examples/remix-zip/**"] + directories: ["/examples/remix-zip/remix-app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -271,7 +285,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/sls", "/examples/sls/**"] + directories: ["/examples/sls/nestjs"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -282,7 +296,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/sqs-expressjs", "/examples/sqs-expressjs/**"] + directories: ["/examples/sqs-expressjs/app/src"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -293,7 +307,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm - directories: ["/examples/sveltekit-ssr-zip", "/examples/sveltekit-ssr-zip/**"] + directories: ["/examples/sveltekit-ssr-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -306,7 +320,7 @@ updates: # nuget - package-ecosystem: nuget - directories: ["/examples/aspnet-mvc", "/examples/aspnet-mvc/**"] + directories: ["/examples/aspnet-mvc/src"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -317,7 +331,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: nuget - directories: ["/examples/aspnet-mvc-zip", "/examples/aspnet-mvc-zip/**"] + directories: ["/examples/aspnet-mvc-zip/src"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -328,7 +342,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: nuget - directories: ["/examples/aspnet-webapi-zip", "/examples/aspnet-webapi-zip/**"] + directories: ["/examples/aspnet-webapi-zip/src"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -341,7 +355,7 @@ updates: # pip - package-ecosystem: pip - directories: ["/examples/bedrock-agent-fastapi", "/examples/bedrock-agent-fastapi/**"] + directories: ["/examples/bedrock-agent-fastapi/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -352,7 +366,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/bedrock-agent-fastapi-zip", "/examples/bedrock-agent-fastapi-zip/**"] + directories: ["/examples/bedrock-agent-fastapi-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -363,7 +377,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/datadog", "/examples/datadog/**"] + directories: ["/examples/datadog/flask/lambda-asset/src"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -374,7 +388,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastapi", "/examples/fastapi/**"] + directories: ["/examples/fastapi/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -385,7 +399,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastapi-backend-only-response-streaming", "/examples/fastapi-backend-only-response-streaming/**"] + directories: ["/examples/fastapi-backend-only-response-streaming/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -396,7 +410,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastapi-background-tasks", "/examples/fastapi-background-tasks/**"] + directories: ["/examples/fastapi-background-tasks/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -407,7 +421,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastapi-response-streaming", "/examples/fastapi-response-streaming/**"] + directories: ["/examples/fastapi-response-streaming/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -418,7 +432,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastapi-response-streaming-lmi", "/examples/fastapi-response-streaming-lmi/**"] + directories: ["/examples/fastapi-response-streaming-lmi/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -429,7 +443,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastapi-response-streaming-zip", "/examples/fastapi-response-streaming-zip/**"] + directories: ["/examples/fastapi-response-streaming-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -440,7 +454,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastapi-snapstart", "/examples/fastapi-snapstart/**"] + directories: ["/examples/fastapi-snapstart/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -451,7 +465,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastapi-snapstart-zip", "/examples/fastapi-snapstart-zip/**"] + directories: ["/examples/fastapi-snapstart-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -462,7 +476,9 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastapi-zip", "/examples/fastapi-zip/**"] + directories: + - "/examples/fastapi-zip/app" + - "/examples/fastapi-zip/tests" schedule: interval: weekly open-pull-requests-limit: 0 @@ -473,7 +489,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fasthtml", "/examples/fasthtml/**"] + directories: ["/examples/fasthtml/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -484,7 +500,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fasthtml-response-streaming", "/examples/fasthtml-response-streaming/**"] + directories: ["/examples/fasthtml-response-streaming/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -495,7 +511,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fasthtml-response-streaming-zip", "/examples/fasthtml-response-streaming-zip/**"] + directories: ["/examples/fasthtml-response-streaming-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -506,7 +522,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fasthtml-zip", "/examples/fasthtml-zip/**"] + directories: ["/examples/fasthtml-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -517,7 +533,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastmcp", "/examples/fastmcp/**"] + directories: ["/examples/fastmcp/my_mcp_server"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -528,7 +544,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/fastmcp-zip", "/examples/fastmcp-zip/**"] + directories: ["/examples/fastmcp-zip/my_mcp_server"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -539,7 +555,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/flask", "/examples/flask/**"] + directories: ["/examples/flask/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -550,7 +566,7 @@ updates: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip - directories: ["/examples/flask-zip", "/examples/flask-zip/**"] + directories: ["/examples/flask-zip/app"] schedule: interval: weekly open-pull-requests-limit: 0 @@ -561,11 +577,11 @@ updates: applies-to: security-updates patterns: ["*"] - # cargo (the adapter itself, not an example). Listed for the commit message only: the - # limit keeps version updates off, and dependabot-automerge.sh refuses anything - # touching files outside examples/, so an advisory here still gets a hand review — it - # just arrives with a header Commit Lint accepts. Pre-emptive: today's six open rust - # alerts are all in examples/rust-*-zip, which the entries above cover. + # cargo, the adapter itself rather than an example. Listed for the commit message + # only: the limit keeps version updates off, and dependabot-automerge.sh refuses + # anything touching files outside examples/, so an advisory here still gets a hand + # review — it just arrives with a header Commit Lint accepts. Pre-emptive: today's six + # open rust alerts are all in examples/rust-*-zip, which the entries above cover. - package-ecosystem: cargo directories: ["/"] schedule: @@ -578,10 +594,10 @@ updates: applies-to: security-updates patterns: ["*"] - # Not an example, and listed for the naming alone. Every workflow here pins actions - # (actions/checkout@v4, Swatinem/rust-cache@v2, orhun/git-cliff-action@v4), so an - # actions advisory would open a pull request titled "bump actions/... from X to Y" — no - # conventional type, red Commit Lint, manual amend. With the limit at 0 this adds no + # Not an example either, and also listed for the naming alone. Every workflow here pins + # actions (actions/checkout@v4, Swatinem/rust-cache@v2, orhun/git-cliff-action@v4), so + # an actions advisory would open a pull request titled "bump actions/... from X to Y" — + # no conventional type, red Commit Lint, manual amend. With the limit at 0 this adds no # pull requests; it only names the ones an advisory would produce anyway. There are no # open actions alerts today, so this is pre-emptive. - package-ecosystem: github-actions diff --git a/.github/scripts/dependabot-automerge.sh b/.github/scripts/dependabot-automerge.sh index 414691c9..4a3291e3 100755 --- a/.github/scripts/dependabot-automerge.sh +++ b/.github/scripts/dependabot-automerge.sh @@ -17,13 +17,25 @@ # behave identically and the verified commit is always the head that would be merged. # # Every not-yet or unresolvable condition exits 0 with a reason, and records it in the -# job summary so a skipped merge is visible instead of buried in a log. Refusing is the -# safe outcome; a red run here would be indistinguishable from a real fault. +# job summary so a skipped merge is visible instead of buried in a log. That includes a +# failed API call: refusing is the safe outcome, and GITHUB_TOKEN's 1,000 requests per +# hour are shared with every other workflow in the repository, so a transient 403 or 5xx +# is not remote. The one deliberate exception is at the very end — a merge that fails +# for no discoverable reason. +# +# The order of the checks is also the order of cost. The cheap, permanent reasons come +# first, so a pull request that can never merge (an example with no build-and-boot +# coverage — most of them) costs two API calls per sweep rather than four. set -euo pipefail PR="${1:?usage: dependabot-automerge.sh }" : "${REPO:?REPO must be set}" +# The matrix on disk, from the checkout the workflow pinned to the default branch. The +# API would return the same content — the sibling selector resolves it the same way — +# but not over the network, and not once per pull request per sweep. +MATRIX="$(dirname "$0")/../example-matrix.json" + # Joins a multi-line list onto one line for a message, without a trailing separator. join_list() { tr '\n' ' ' <<<"$1" | sed 's/ *$//' @@ -37,21 +49,20 @@ skip() { exit 0 } -author=$(gh pr view "$PR" --repo "$REPO" --json author -q .author.login) +# One request for everything the pull request itself can tell us. Local jq over the +# result is not wrapped: unlike the request, it cannot fail transiently, and a parse +# failure there is a real fault that should be loud. +if ! pr_json=$(gh pr view "$PR" --repo "$REPO" \ + --json author,headRefOid,statusCheckRollup); then + skip "could not read the pull request." +fi + +author=$(jq -r '.author.login' <<<"$pr_json") if [[ "$author" != "app/dependabot" && "$author" != "dependabot[bot]" ]]; then skip "authored by $author, not Dependabot." fi -head_sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid) - -# A run for a superseded commit proves nothing about what would be merged, and -# Dependabot force-pushes these branches whenever it rebases. -run_id=$(gh api \ - "repos/$REPO/actions/workflows/examples.yaml/runs?head_sha=$head_sha&status=success&per_page=1" \ - -q '.workflow_runs[0].id // empty') -if [[ -z "$run_id" ]]; then - skip "no successful Verify Examples run for $head_sha (yet)." -fi +head_sha=$(jq -r '.headRefOid' <<<"$pr_json") # Verify Examples is not the only check. Commit Lint runs on every pull request with no # path filter and does go red on Dependabot pull requests (#799), and any check added @@ -61,22 +72,24 @@ fi # This workflow's own run is excluded defensively: workflow_run runs do not appear in a # pull request's check rollup today, but if that changed, its in-progress state would # deadlock every merge. -not_green=$(gh pr view "$PR" --repo "$REPO" --json statusCheckRollup -q ' +not_green=$(jq -r ' .statusCheckRollup[] | select((.workflowName // "") != "Dependabot Auto-merge") | select([((.conclusion // .state // "PENDING") | ascii_upcase)] - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0) - | ((.name // .context) + " = " + (.conclusion // .state // "PENDING"))') + | ((.name // .context) + " = " + (.conclusion // .state // "PENDING"))' <<<"$pr_json") if [[ -n "$not_green" ]]; then skip "checks are not all green: $(join_list "$not_green")" fi # Fail closed: an empty file list must never read as "nothing outside examples/". The # API call is kept out of the pipeline so only grep's no-match status is tolerated. -files=$(gh api --paginate "repos/$REPO/pulls/$PR/files" -q '.[].filename') -if [[ -z "$files" ]]; then +if ! files=$(gh api --paginate "repos/$REPO/pulls/$PR/files" -q '.[].filename'); then skip "could not list its files." fi +if [[ -z "$files" ]]; then + skip "its file list came back empty." +fi # Scope by what the pull request changes rather than by its branch name: grouped updates # do not reliably encode the directory in the ref. Examples are demo apps, so a bad bump @@ -89,30 +102,38 @@ fi changed_examples=$(cut -d/ -f2 <<<"$files" | sort -u) -# Refuse to merge an example that was not built and booted. Proof comes from the run's -# own job names, so a run that predates a matrix change cannot be credited with -# verifying an example it never launched. If GitHub ever changes how matrix jobs are -# named, this stops finding matches and merges stop — fail-closed, and visible in the -# summary rather than silent. -verified_examples=$(gh api "repos/$REPO/actions/runs/$run_id/jobs" --paginate \ - -q '.jobs[] | select(.name | startswith("test-")) | select(.conclusion == "success") | .name' \ - | sed -E 's/^test-[a-z]+ \(([^,)]+).*/\1/' | sort -u) +# An example with no matrix entry can never be verified, so decide that before spending +# any more calls on it. +covered=$(jq -r '[.[][].name] | unique | .[]' "$MATRIX" | sort -u) +uncovered=$(comm -23 <(printf '%s\n' "$changed_examples") <(printf '%s\n' "$covered") || true) +if [[ -n "$uncovered" ]]; then + skip "no build-and-boot coverage for: $(join_list "$uncovered") — add them to .github/example-matrix.json, or review by hand." +fi + +# A run for a superseded commit proves nothing about what would be merged, and +# Dependabot force-pushes these branches whenever it rebases. +if ! run_id=$(gh api \ + "repos/$REPO/actions/workflows/examples.yaml/runs?head_sha=$head_sha&status=success&per_page=1" \ + -q '.workflow_runs[0].id // empty'); then + skip "could not look up its Verify Examples runs." +fi +if [[ -z "$run_id" ]]; then + skip "no successful Verify Examples run for $head_sha (yet)." +fi + +# Coverage in the matrix is necessary but not sufficient: proof that an example was +# actually built and booted comes from the run's own job names, so a run predating a +# matrix change cannot be credited with verifying an example it never launched. If +# GitHub ever changes how matrix jobs are named, this stops finding matches and merges +# stop — fail-closed, and visible in the summary rather than silent. +if ! job_names=$(gh api "repos/$REPO/actions/runs/$run_id/jobs" --paginate \ + -q '.jobs[] | select(.name | startswith("test-")) | select(.conclusion == "success") | .name'); then + skip "could not list the jobs of run $run_id." +fi +verified_examples=$(sed -E 's/^test-[a-z]+ \(([^,)]+).*/\1/' <<<"$job_names" | sort -u) unverified=$(comm -23 <(printf '%s\n' "$changed_examples") <(printf '%s\n' "$verified_examples") || true) if [[ -n "$unverified" ]]; then - # Distinguish "not covered by the matrix at all" from "covered but not run", because - # the fixes differ: add a matrix entry, versus re-run the verification. - if ! matrix_json=$(gh api "repos/$REPO/contents/.github/example-matrix.json" \ - -H 'Accept: application/vnd.github.raw'); then - skip "could not read .github/example-matrix.json." - fi - if ! covered=$(jq -r '[.[][].name] | unique | .[]' <<<"$matrix_json" | sort -u); then - skip "could not parse .github/example-matrix.json." - fi - uncovered=$(comm -23 <(printf '%s\n' "$unverified") <(printf '%s\n' "$covered") || true) - if [[ -n "$uncovered" ]]; then - skip "no build-and-boot coverage for: $(join_list "$uncovered") — add them to .github/example-matrix.json, or review by hand." - fi - skip "these examples are in the matrix but were not verified by run $run_id: $(join_list "$unverified")" + skip "in the matrix but not verified by run $run_id: $(join_list "$unverified")" fi echo "PR #$PR is example-only and verified at $head_sha by run $run_id. Merging." diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index f45a35f9..4b0a2bec 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -30,10 +30,19 @@ on: - cron: "17 * * * *" workflow_dispatch: -# One at a time: the two triggers can overlap, and two runs racing to merge the same -# pull request would just make one of them report a conflict it did not cause. +# Keyed per pull request, not one group for everything. Only one run per group may be +# pending, and a new arrival cancels the pending one — so with a single group, a burst of +# Verify Examples runs finishing together (the normal case, since Dependabot opens its +# security pull requests in batches) would cancel each other's queued fast-path runs +# until only the last survived, leaving the rest to the hourly sweep and filling the run +# history with cancellations that read like failures. +# +# The cost is that a sweep run and a fast-path run can now overlap on the same pull +# request. That is handled where it actually has to be: --match-head-commit plus the +# mergeStateStatus branch in dependabot-automerge.sh turn the loser into a skip with a +# reason rather than a red run. concurrency: - group: dependabot-automerge + group: dependabot-automerge-${{ github.event.workflow_run.pull_requests[0].number || 'sweep' }} cancel-in-progress: false permissions: From 1d2c360d082e73a509c6403797f2f64221e87102 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 19:08:22 +0000 Subject: [PATCH 12/23] ci: four review fixes, and a guard against example config drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Null check rollup no longer aborts the merge script. `gh pr view --json statusCheckRollup` emits null, not [], when the head commit has no check runs yet — a real few-second window every time Dependabot force-pushes a rebase — and `.statusCheckRollup[]` over null makes jq exit, taking the script with it under set -e. The sweep's pre-filter already used `[]?`, which is what made the combination reachable: it treats such a pull request as all-green and hands it straight to the script. Both now use `[]?`. The sweep's `gh pr list` is fail-soft like every lookup in the script. It was the one call left that would abort the step, and on an hourly cron an unexplained red run is worse than a quiet skip, since the next sweep picks up whatever was missed. The selector derives the diff base from the merge ref rather than the payload. `github.event.pull_request.base.sha` can be older than the tip refs/pull/N/merge was recomputed against, in which case merge-base returns the payload SHA and the diff also contains everything that landed on main in between — measured on a real merge commit here, one file becomes five, and a single intervening commit under src/ would then verify all eighteen examples. HEAD^1 is the base the merge was actually computed against, so HEAD^1..HEAD is exactly the pull request's contribution. The payload path remains as the fallback for a non-merge checkout, and its "base not fetched" branch moved with it. Add .github/scripts/check-example-config.sh, run from the validate job. A manifest under examples/ with no dependabot.yml entry does not just lose grouping: it reverts to one pull request per advisory with a commit header Commit Lint rejects — the exact state the config exists to prevent — and that is invisible until the pull requests appear weeks later. With 47 entries covering 53 manifest directories, drift is a matter of when. The check also catches a stale entry pointing at a directory with no manifest, and an example-matrix.json name that is not a directory under examples/, which would otherwise hand out coverage for an example that no longer exists. dependabot.yml joins the workflow's trigger paths so editing it runs the check. Twenty-two paths exercised: the selector's six base-resolution and selection paths, and the merge script's sixteen guards including the null rollup and all four API failures. The drift guard was exercised against four deliberate drifts — a new unclaimed manifest, a ghost matrix name, a stale entry, and the clean tree. --- .github/scripts/check-example-config.sh | 115 ++++++++++++++++++++ .github/scripts/dependabot-automerge.sh | 7 +- .github/scripts/select-examples.sh | 23 +++- .github/workflows/dependabot-automerge.yaml | 23 ++-- .github/workflows/examples.yaml | 10 ++ 5 files changed, 164 insertions(+), 14 deletions(-) create mode 100755 .github/scripts/check-example-config.sh diff --git a/.github/scripts/check-example-config.sh b/.github/scripts/check-example-config.sh new file mode 100755 index 00000000..9fdc8e03 --- /dev/null +++ b/.github/scripts/check-example-config.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# Asserts that the two hand-maintained lists describing the examples have not drifted +# from the tree: +# +# 1. Every dependency manifest under examples/ is claimed by an entry in +# .github/dependabot.yml, for its own ecosystem. +# 2. Every name in .github/example-matrix.json is a real directory under examples/. +# +# Why this is a check and not a note in a comment. An unclaimed manifest does not merely +# lose grouping: it silently reverts to Dependabot's default behavior — one pull request +# per advisory per manifest, which is the thirteen-against-one-lockfile pattern the +# config exists to fix — with a default commit header that fails commitlint's type-enum. +# That regression is invisible until the pull requests appear, weeks later, and with 47 +# entries covering 53 manifest directories the drift is a matter of when, not if. +# examples/fastmcp and examples/sveltekit-ssr-zip are recent evidence that examples get +# added regularly. +# +# A stale matrix name is quieter but worse: dependabot-automerge.sh treats a changed +# example as covered when the matrix names it, so a renamed or deleted example would be +# credited with coverage it does not have. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +DEPENDABOT=.github/dependabot.yml +MATRIX=.github/example-matrix.json + +python3 - "$DEPENDABOT" "$MATRIX" <<'PY' +import fnmatch +import json +import subprocess +import sys + +dependabot_path, matrix_path = sys.argv[1], sys.argv[2] + +try: + import yaml +except ImportError: # pragma: no cover - only on a runner without PyYAML + sys.exit("PyYAML is required: pip install pyyaml") + +# Ecosystem -> manifest filenames Dependabot keys off. Only the ones present under +# examples/; add a row when an example introduces a new ecosystem. +MANIFESTS = { + "npm": ["package.json"], + "pip": ["requirements.txt"], + "gomod": ["go.mod"], + "maven": ["pom.xml"], + "nuget": ["*.csproj"], + "cargo": ["Cargo.toml"], + "bundler": ["Gemfile"], +} + +tracked = subprocess.run( + ["git", "ls-files", "examples"], capture_output=True, text=True, check=True +).stdout.split() + +# (ecosystem, directory) pairs the tree actually contains. +found = set() +for path in tracked: + parts = path.split("/") + if len(parts) < 2: + continue + directory = "/" + "/".join(parts[:-1]) + for ecosystem, patterns in MANIFESTS.items(): + if any(fnmatch.fnmatch(parts[-1], pattern) for pattern in patterns): + found.add((ecosystem, directory)) + +configured = set() +config = yaml.safe_load(open(dependabot_path)) +for update in config["updates"]: + for directory in update.get("directories", []): + configured.add((update["package-ecosystem"], directory)) + +problems = [] + +unclaimed = sorted(found - configured) +if unclaimed: + problems.append( + "These manifests have no matching entry in %s, so Dependabot will open one pull\n" + "request per advisory for them, with a commit header Commit Lint rejects:\n%s" + % (dependabot_path, "\n".join(f" {eco}: {d}" for eco, d in unclaimed)) + ) + +# The reverse direction, limited to examples/: an entry for a directory that no longer +# has a manifest is dead config, and usually means an example was renamed. +stale = sorted( + (eco, d) for eco, d in configured - found if d.startswith("/examples/") +) +if stale: + problems.append( + "These %s entries point at directories with no matching manifest:\n%s" + % (dependabot_path, "\n".join(f" {eco}: {d}" for eco, d in stale)) + ) + +matrix = json.load(open(matrix_path)) +names = {entry["name"] for group in matrix.values() for entry in group} +import os + +missing = sorted(n for n in names if not os.path.isdir(os.path.join("examples", n))) +if missing: + problems.append( + "These %s names are not directories under examples/:\n%s" + % (matrix_path, "\n".join(f" {n}" for n in missing)) + ) + +if problems: + print("\n\n".join(problems)) + sys.exit(1) + +print( + f"{len(found)} manifest directories under examples/ are all claimed by " + f"{dependabot_path}, and every {matrix_path} name exists." +) +PY diff --git a/.github/scripts/dependabot-automerge.sh b/.github/scripts/dependabot-automerge.sh index 4a3291e3..b167a4e4 100755 --- a/.github/scripts/dependabot-automerge.sh +++ b/.github/scripts/dependabot-automerge.sh @@ -72,8 +72,13 @@ head_sha=$(jq -r '.headRefOid' <<<"$pr_json") # This workflow's own run is excluded defensively: workflow_run runs do not appear in a # pull request's check rollup today, but if that changed, its in-progress state would # deadlock every merge. +# +# `[]?` rather than `[]`: gh emits null, not [], when the head commit has no check runs +# yet — a real few-second window every time Dependabot force-pushes a rebase — and +# iterating null aborts jq. The sweep's pre-filter already tolerates it, which is what +# makes the combination reachable: it would pass such a pull request straight to here. not_green=$(jq -r ' - .statusCheckRollup[] + .statusCheckRollup[]? | select((.workflowName // "") != "Dependabot Auto-merge") | select([((.conclusion // .state // "PENDING") | ascii_upcase)] - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0) diff --git a/.github/scripts/select-examples.sh b/.github/scripts/select-examples.sh index f9d287b3..563b864f 100755 --- a/.github/scripts/select-examples.sh +++ b/.github/scripts/select-examples.sh @@ -36,15 +36,28 @@ if [[ -z "${BASE_SHA:-}" ]]; then exit 0 fi -if ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then +# HEAD is refs/pull/N/merge, so its first parent is the base tip the merge was computed +# against and its second is the pull request head. HEAD^1..HEAD is therefore exactly the +# pull request's contribution. +# +# Not merge-base with BASE_SHA: that comes from the event payload and can be older than +# the tip the merge ref was recomputed against, in which case merge-base returns +# BASE_SHA itself and the diff also picks up everything that landed on main in between. +# One intervening commit under src/ then trips the shared-path rule below and verifies +# all eighteen examples — measured on a real merge commit here, one file becomes five. +# It over-selects rather than under-selects, so it is a cost rather than a hole, but +# re-running an older pull request is routine enough to be worth avoiding. +if git rev-parse --verify --quiet HEAD^2 >/dev/null; then + base="$(git rev-parse HEAD^1)" +elif git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then + base="$(git merge-base "$BASE_SHA" HEAD)" +else + # Only reachable when HEAD is not a merge ref and the payload's base is not in this + # clone — a shallow fetch, or a fork whose base was never fetched. echo "Base commit $BASE_SHA is not available locally: verifying every example." emit_all exit 0 fi - -# HEAD is the pull request's merge commit, so diffing from the merge base yields -# exactly the changes the PR contributes. -base="$(git merge-base "$BASE_SHA" HEAD)" changed="$(git diff --name-only "$base" HEAD)" echo "Changed files:" echo "$changed" | sed 's/^/ /' diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index 4b0a2bec..6b2e365c 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -83,13 +83,17 @@ jobs: else # One request covers the whole queue, so the sweep can drop the pull # requests that obviously cannot merge — conflicted, or a check red or - # pending — before spending ~6 API calls each on them in the script. Only - # CONFLICTING is excluded on mergeability, not "anything but MERGEABLE": - # GitHub reports UNKNOWN while it recomputes, and dropping those could skip - # a pull request forever. With - # ~70 open Dependabot pull requests that is the difference between a cheap - # hourly sweep and one that eats the token's hourly budget. - prs=$(gh pr list --repo "$REPO" --author app/dependabot --state open \ + # pending — before spending API calls on them in the script. With ~70 open + # Dependabot pull requests that is the difference between a cheap hourly + # sweep and one that eats the token's hourly budget. + # + # Only CONFLICTING is excluded on mergeability, not "anything but + # MERGEABLE": GitHub reports UNKNOWN while it recomputes, and dropping those + # could skip a pull request forever. + # + # Fail-soft like every lookup in the script: an hourly red run that means + # nothing is worse than a quiet skip, and the next sweep retries anyway. + if ! prs=$(gh pr list --repo "$REPO" --author app/dependabot --state open \ --limit 200 --json number,mergeable,statusCheckRollup -q ' .[] | select(.mergeable != "CONFLICTING") @@ -97,7 +101,10 @@ jobs: | select([((.conclusion // .state // "PENDING") | ascii_upcase)] - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0)] | length == 0) - | .number') + | .number'); then + echo "Could not list open Dependabot pull requests; the next sweep retries." + exit 0 + fi fi if [[ -z "$prs" ]]; then diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index d6bb3ba6..9dbafd4e 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -14,6 +14,7 @@ on: - ".github/workflows/examples.yaml" - ".github/scripts/**" - ".github/example-matrix.json" + - ".github/dependabot.yml" push: branches: - main @@ -69,6 +70,15 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} + # The two hand-maintained lists describing the examples drift silently otherwise: + # an unclaimed manifest reverts to one Dependabot pull request per advisory with a + # commit header Commit Lint rejects, and that is invisible until the pull requests + # appear weeks later. + - name: Check the example config lists against the tree + run: | + pip install pyyaml --quiet + .github/scripts/check-example-config.sh + - name: Validate all SAM templates run: | failed=0 From b21954d519dd7f18b8a64bc078e534964f4772f8 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 19:23:41 +0000 Subject: [PATCH 13/23] ci: complete the manifest table, require an open PR, fix a stale comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the eighth review on #844. The drift guard recognised only requirements.txt for pip, which broke it in both directions. An example shipping only a pyproject.toml — the mainstream Python layout — would not appear in the found set, so the unclaimed-manifest assertion would pass while Dependabot opened one ungrouped pull request per advisory for it; and a maintainer who added the correct entry for such a directory would have been told the entry was stale, failing validate on a correct config. Both reproduced against a scratch example. pip now lists pyproject.toml, Pipfile and setup.py, nuget covers the other project types and packages.config, bundler covers gemspecs, and gradle and composer are listed although no example uses them yet, so the next one that does is caught. docker stays out deliberately, with the reason recorded: 24 Dockerfiles are under examples/, but Dependabot alerts do not cover base images, so an entry for them would not exist and the check would fail for its absence. Require the pull request to still be open. A closed one passes every other gate — its rollup is still green, its files are unchanged, and --match-head-commit matches a head that never moved — so it reached `gh pr merge` and the outcome rested on whatever mergeStateStatus reports for a merged pull request; anything outside the expected set would have exited 1 and turned the sweep red for a benign race. The overlap that produces this is one the concurrency keys deliberately allow. The state comes from the request already being made, and is re-checked in the failure branch for a merge that lands mid-run. Fix the dependabot.yml comment claiming "Verify Examples does not check for that", which the check-example-config.sh added in this same branch contradicts. It now points at the script, so a maintainer who hits the check knows where it comes from. Nineteen merge-script paths re-exercised after the change, including both new closed-pull-request windows and a failed re-read, plus the drift guard against a pyproject-only example in both the missing-entry and correct-entry states. --- .github/dependabot.yml | 6 ++++-- .github/scripts/check-example-config.sh | 22 +++++++++++++++++----- .github/scripts/dependabot-automerge.sh | 24 +++++++++++++++++++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a2f4e930..f41190ba 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,8 +24,10 @@ # whose entries for one ecosystem it cannot prove have non-overlapping directories, and # it cannot prove that for globs — "Dependabot cannot determine if 'npm' has overlapping # directories". Each entry therefore lists the manifest directories inside its example. -# `git ls-files examples` is how this list was derived; a new example needs an entry -# here, and Verify Examples does not check for that. +# `git ls-files examples` is how this list was derived. A new example needs an entry +# here; .github/scripts/check-example-config.sh enforces that from Verify Examples, so a +# missing one fails the pull request that adds the example rather than surfacing weeks +# later as ungrouped pull requests. # # `open-pull-requests-limit: 0` disables *version* updates and leaves *security* updates # on, which keeps the current behavior: pull requests for advisories only, not for every diff --git a/.github/scripts/check-example-config.sh b/.github/scripts/check-example-config.sh index 9fdc8e03..72bc463f 100755 --- a/.github/scripts/check-example-config.sh +++ b/.github/scripts/check-example-config.sh @@ -39,16 +39,28 @@ try: except ImportError: # pragma: no cover - only on a runner without PyYAML sys.exit("PyYAML is required: pip install pyyaml") -# Ecosystem -> manifest filenames Dependabot keys off. Only the ones present under -# examples/; add a row when an example introduces a new ecosystem. +# Ecosystem -> every manifest filename Dependabot keys off for it. One name per +# ecosystem is not enough: an example shipping only a pyproject.toml is still pip, and +# missing that name would break the guard in both directions — the drift would pass +# unnoticed, and a maintainer who added the correct entry for it would be told the entry +# is stale. +# +# Ecosystems with no example today are listed anyway, so the next example that +# introduces one is caught rather than silently unguarded. +# +# Deliberately absent: docker. There are 24 Dockerfiles under examples/, but Dependabot +# alerts do not cover base images, so there is nothing for a security-updates entry to +# group — including it here would fail this check for entries that should not exist. MANIFESTS = { "npm": ["package.json"], - "pip": ["requirements.txt"], + "pip": ["requirements.txt", "pyproject.toml", "Pipfile", "setup.py"], "gomod": ["go.mod"], "maven": ["pom.xml"], - "nuget": ["*.csproj"], + "gradle": ["build.gradle", "build.gradle.kts"], + "nuget": ["*.csproj", "*.fsproj", "*.vbproj", "packages.config"], "cargo": ["Cargo.toml"], - "bundler": ["Gemfile"], + "bundler": ["Gemfile", "*.gemspec"], + "composer": ["composer.json"], } tracked = subprocess.run( diff --git a/.github/scripts/dependabot-automerge.sh b/.github/scripts/dependabot-automerge.sh index b167a4e4..8a3bf9a2 100755 --- a/.github/scripts/dependabot-automerge.sh +++ b/.github/scripts/dependabot-automerge.sh @@ -53,7 +53,7 @@ skip() { # result is not wrapped: unlike the request, it cannot fail transiently, and a parse # failure there is a real fault that should be loud. if ! pr_json=$(gh pr view "$PR" --repo "$REPO" \ - --json author,headRefOid,statusCheckRollup); then + --json author,headRefOid,state,statusCheckRollup); then skip "could not read the pull request." fi @@ -62,6 +62,17 @@ if [[ "$author" != "app/dependabot" && "$author" != "dependabot[bot]" ]]; then skip "authored by $author, not Dependabot." fi +# A closed pull request passes every other gate — its rollup is still green, its files +# are unchanged, and --match-head-commit still matches a head that never moved — so +# without this it would reach `gh pr merge` and the outcome would rest on whatever +# mergeStateStatus happens to report for a merged pull request. Reachable through the +# overlap the concurrency keys deliberately allow: a sweep run and a fast-path run on +# the same pull request, where the loser is merging something already merged. +pr_state=$(jq -r '.state' <<<"$pr_json") +if [[ "$pr_state" != "OPEN" ]]; then + skip "state is $pr_state, not OPEN." +fi + head_sha=$(jq -r '.headRefOid' <<<"$pr_json") # Verify Examples is not the only check. Commit Lint runs on every pull request with no @@ -160,8 +171,15 @@ if gh pr merge "$PR" --repo "$REPO" --squash --delete-branch \ exit 0 fi -head_after=$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid || echo unknown) -state=$(gh pr view "$PR" --repo "$REPO" --json mergeStateStatus -q .mergeStateStatus || echo UNKNOWN) +if ! after=$(gh pr view "$PR" --repo "$REPO" --json headRefOid,state,mergeStateStatus); then + skip "merge failed and the pull request could not be re-read." +fi +head_after=$(jq -r '.headRefOid' <<<"$after") +state=$(jq -r '.mergeStateStatus' <<<"$after") +# The same race as above, but landing between the check and this call. +if [[ "$(jq -r '.state' <<<"$after")" != "OPEN" ]]; then + skip "merge rejected, the pull request is no longer open — something else landed it." +fi if [[ "$head_after" != "$head_sha" ]]; then skip "merge rejected, head moved to $head_after." fi From bb4e34426c402d61dd83e6ff2bb632f574fdd7bc Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 20:17:03 +0000 Subject: [PATCH 14/23] ci: grant checks/statuses, fail closed on an absent rollup, widen push paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from the ninth review on #844. The first is a fail-open hole. Grant checks: read and statuses: read. statusCheckRollup is the gate both the workflow and the merge script depend on, and it is built from Checks resources and commit statuses — neither of which a permissions block naming only contents, pull-requests and actions grants. Either GraphQL errors, making the workflow a permanent no-op that looks healthy, or it returns the field empty, which `[]?` would have swallowed as "all checks green" and merged past a red Commit Lint. That is the exact check #799 shows going red on a Dependabot pull request. Belt and braces on the same hole: the script now refuses outright when statusCheckRollup is not an array, so neither an absent permission nor a head with no checks yet can read as success. It used to merge in that case; it now skips and lets the sweep look again. The drift guard read only the plural `directories`. The singular `directory` is equally valid and is the canonical form for one directory, so an entry using it contributed nothing and its manifest was reported as unclaimed — validate failing on correct config, with a message telling the author to add an entry already in the file. Both spellings are read now. The drift guard also asserts the two keys the grouping actually rests on. `applies-to: security-updates` is load-bearing because plain groups batch version updates only, and those are off via the limit; a 48th entry copy-pasted without it would pass every check here while its advisories reverted to one pull request each. A missing or non-zero open-pull-requests-limit is the mirror image. With 47 near-identical entries these are exactly the copy-paste omissions worth machine checking. Widen the push trigger to every input the selector calls shared. It listed only src/**, so layer/**, Cargo.toml and Cargo.lock were verified against the examples on no event at all — and layer/bootstrap is the code path all eight zip examples boot, since build-layer copies it into the artifact each of them injects. The comment claiming those changes are covered on push to main is now true. Pull request triggers are unchanged, so source pull requests still do not fan out to eighteen jobs. Twenty merge-script paths re-run with expected exit codes, zero mismatches, plus the drift guard against a singular-directory entry, a missing applies-to, a non-zero limit, and the clean tree. --- .github/scripts/check-example-config.sh | 34 ++++++++++++++++++--- .github/scripts/dependabot-automerge.sh | 6 ++++ .github/workflows/dependabot-automerge.yaml | 8 +++++ .github/workflows/examples.yaml | 16 +++++++--- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/.github/scripts/check-example-config.sh b/.github/scripts/check-example-config.sh index 72bc463f..3b8c3790 100755 --- a/.github/scripts/check-example-config.sh +++ b/.github/scripts/check-example-config.sh @@ -79,12 +79,38 @@ for path in tracked: found.add((ecosystem, directory)) configured = set() +problems = [] config = yaml.safe_load(open(dependabot_path)) for update in config["updates"]: - for directory in update.get("directories", []): - configured.add((update["package-ecosystem"], directory)) - -problems = [] + ecosystem = update["package-ecosystem"] + + # Both spellings are valid Dependabot config. This file uses the plural throughout, + # but the singular is the canonical form for one directory and is what someone + # adding an entry is likely to reach for — reading only the plural would report + # their manifest as unclaimed and tell them to add an entry that is already there. + directories = list(update.get("directories") or []) + if "directory" in update: + directories.append(update["directory"]) + for directory in directories: + configured.add((ecosystem, directory)) + + where = f"{ecosystem} {directories}" + + # Both keys below are load-bearing, and an entry copy-pasted without either looks + # correct here while silently reverting that example to what this file exists to + # prevent. Plain `groups` batches version updates only, so without + # `applies-to: security-updates` the grouping does not apply to the advisories that + # are the whole point. + groups = update.get("groups") or {} + if not any(g.get("applies-to") == "security-updates" for g in groups.values()): + problems.append(f"{where}: needs a group with `applies-to: security-updates`, " + "or its security updates arrive one pull request per advisory.") + + # And a missing or non-zero limit turns routine version bumps back on for that one + # example. + if update.get("open-pull-requests-limit") != 0: + problems.append(f"{where}: needs `open-pull-requests-limit: 0`, or version " + "updates come back on for it.") unclaimed = sorted(found - configured) if unclaimed: diff --git a/.github/scripts/dependabot-automerge.sh b/.github/scripts/dependabot-automerge.sh index 8a3bf9a2..a233e689 100755 --- a/.github/scripts/dependabot-automerge.sh +++ b/.github/scripts/dependabot-automerge.sh @@ -88,6 +88,12 @@ head_sha=$(jq -r '.headRefOid' <<<"$pr_json") # yet — a real few-second window every time Dependabot force-pushes a rebase — and # iterating null aborts jq. The sweep's pre-filter already tolerates it, which is what # makes the combination reachable: it would pass such a pull request straight to here. +# An absent rollup must never read as "everything passed": that is the shape a missing +# checks/statuses permission would produce, and `[]?` alone would swallow it and merge. +if [[ "$(jq -r '.statusCheckRollup | type' <<<"$pr_json")" != "array" ]]; then + skip "no check rollup available for $head_sha (yet)." +fi + not_green=$(jq -r ' .statusCheckRollup[]? | select((.workflowName // "") != "Dependabot Auto-merge") diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index 6b2e365c..d6f76f3d 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -52,6 +52,14 @@ permissions: # permissions block sets every scope not named here to none, so without this the # Actions API returns 403. Same reason commitlint-comment.yaml declares it. actions: read + # statusCheckRollup is the gate both this file and dependabot-automerge.sh depend on, + # and it is made of Checks resources (CheckRun nodes) and commit statuses + # (StatusContext nodes). Without these two the query either errors — making the whole + # workflow a no-op that looks healthy — or comes back empty, which would read as "all + # checks green" and merge past a red Commit Lint. The script also refuses an absent + # rollup outright, so neither failure can be mistaken for success. + checks: read + statuses: read jobs: merge: diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index 9dbafd4e..503a5aee 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -18,13 +18,19 @@ on: push: branches: - main + # Every input select-examples.sh treats as shared, not just src/. layer/bootstrap in + # particular is the code path all eight zip examples boot — build-layer copies it + # into the artifact each of them injects — so leaving it out meant a regression there + # was verified by nothing at all. paths: - "src/**" - # Note that src/, layer/ and Cargo.* are deliberately NOT pull request triggers, - # even though select-examples.sh treats them as shared: adding them would run all - # eighteen matrix entries on every source pull request. Adapter changes are verified - # against the examples on push to main (above), and the selector's shared-path rule - # still applies to a pull request that touches both source and examples. + - "layer/**" + - "Cargo.toml" + - "Cargo.lock" + # Those same paths are deliberately NOT pull request triggers: adding them would run + # all eighteen matrix entries on every source pull request. Adapter changes are + # verified against the examples on push to main (above), and the selector's shared-path + # rule still applies to a pull request that touches both source and examples. workflow_dispatch: permissions: From 8ee5b00aa361ae365c3a919b9ce78f929d9f8b04 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 20:51:33 +0000 Subject: [PATCH 15/23] ci: shorten the group names, and narrow what counts as shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from the tenth review on #844. Every group is now named `security` rather than after its example and ecosystem. Dependabot builds the commit header from the group name and the directory, and commitlint caps the header at 120 characters, which three of the 55 directories already exceeded: 137 chore: bump the fastapi-backend-only-response-streaming-pip group in ... 127 chore: bump the springboot-response-streaming-zip-maven group in ... 121 chore: bump the fasthtml-response-streaming-zip-pip group in ... The consequence is the one this branch exists to remove: Commit Lint red, dependabot-automerge.sh refusing on "checks are not all green", and a hand amend needed for exactly the examples with the longest names. The name only has to be unique within its own entry, and the directory in the header is what distinguishes the pull requests, so encoding the example in it bought nothing. Worst header is now 103 characters, none over the limit. check-example-config.sh asserts on the group's applies-to value rather than its name, so it needed no change. The selector's shared-path rule named .github/scripts/ as a directory, which since this branch also holds dependabot-automerge.sh and check-example-config.sh — neither of which any example is built against. Combined with .github/scripts/** in the workflow's pull request paths, a one-line fix to the auto-merge script rebuilt and booted all eighteen entries: precisely the cost this branch exists to remove, reintroduced through a glob. The rule now names verify-http.sh and select-examples.sh, the two scripts every test job actually runs, and the trigger names the three scripts this workflow uses at all. While there: build-layer only runs when `select` chose something. The artifact exists for the test jobs, so building it for an empty matrix was several minutes of runner time for nobody. --- .github/dependabot.yml | 106 ++++++++++++++++------------- .github/scripts/select-examples.sh | 11 ++- .github/workflows/examples.yaml | 11 ++- 3 files changed, 75 insertions(+), 53 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f41190ba..7b5fc4ea 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -37,6 +37,14 @@ # `commit-message.prefix` is what keeps Commit Lint green: it runs on every pull request # with no path filter, and Dependabot's default message has no conventional type. #799 is # the evidence — commit "bump com.fasterxml.jackson.core:jackson-databind", check red. +# +# Every group is named `security` rather than after its example, because Dependabot +# builds the commit header from the group name and the directory, and commitlint caps the +# header at 120 characters. `fastapi-backend-only-response-streaming-pip` against its own +# 53-character directory produced a 137-character header — Commit Lint red, auto-merge +# refusing on "checks are not all green", and a hand amend needed for exactly the +# examples with the longest names. The name only has to be unique within its entry; the +# directory in the header is what distinguishes the pull requests. version: 2 updates: # bundler @@ -48,7 +56,7 @@ updates: commit-message: prefix: chore groups: - sinatra-bundler: + security: applies-to: security-updates patterns: ["*"] @@ -61,7 +69,7 @@ updates: commit-message: prefix: chore groups: - rust-actix-web-zip-cargo: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: cargo @@ -72,7 +80,7 @@ updates: commit-message: prefix: chore groups: - rust-axum-zip-cargo: + security: applies-to: security-updates patterns: ["*"] @@ -85,7 +93,7 @@ updates: commit-message: prefix: chore groups: - gin-gomod: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: gomod @@ -96,7 +104,7 @@ updates: commit-message: prefix: chore groups: - gin-zip-gomod: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: gomod @@ -107,7 +115,7 @@ updates: commit-message: prefix: chore groups: - go-http-zip-gomod: + security: applies-to: security-updates patterns: ["*"] @@ -120,7 +128,7 @@ updates: commit-message: prefix: chore groups: - javalin-zip-maven: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: maven @@ -131,7 +139,7 @@ updates: commit-message: prefix: chore groups: - springboot-maven: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: maven @@ -142,7 +150,7 @@ updates: commit-message: prefix: chore groups: - springboot-response-streaming-zip-maven: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: maven @@ -153,7 +161,7 @@ updates: commit-message: prefix: chore groups: - springboot-zip-maven: + security: applies-to: security-updates patterns: ["*"] @@ -166,7 +174,7 @@ updates: commit-message: prefix: chore groups: - bun-graphql-streaming-zip-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -177,7 +185,7 @@ updates: commit-message: prefix: chore groups: - bun-graphql-zip-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -193,7 +201,7 @@ updates: commit-message: prefix: chore groups: - datadog-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -206,7 +214,7 @@ updates: commit-message: prefix: chore groups: - datadog-zip-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -217,7 +225,7 @@ updates: commit-message: prefix: chore groups: - expressjs-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -228,7 +236,7 @@ updates: commit-message: prefix: chore groups: - expressjs-zip-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -239,7 +247,7 @@ updates: commit-message: prefix: chore groups: - nextjs-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -250,7 +258,7 @@ updates: commit-message: prefix: chore groups: - nextjs-response-streaming-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -261,7 +269,7 @@ updates: commit-message: prefix: chore groups: - nextjs-zip-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -272,7 +280,7 @@ updates: commit-message: prefix: chore groups: - remix-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -283,7 +291,7 @@ updates: commit-message: prefix: chore groups: - remix-zip-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -294,7 +302,7 @@ updates: commit-message: prefix: chore groups: - sls-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -305,7 +313,7 @@ updates: commit-message: prefix: chore groups: - sqs-expressjs-npm: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: npm @@ -316,7 +324,7 @@ updates: commit-message: prefix: chore groups: - sveltekit-ssr-zip-npm: + security: applies-to: security-updates patterns: ["*"] @@ -329,7 +337,7 @@ updates: commit-message: prefix: chore groups: - aspnet-mvc-nuget: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: nuget @@ -340,7 +348,7 @@ updates: commit-message: prefix: chore groups: - aspnet-mvc-zip-nuget: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: nuget @@ -351,7 +359,7 @@ updates: commit-message: prefix: chore groups: - aspnet-webapi-zip-nuget: + security: applies-to: security-updates patterns: ["*"] @@ -364,7 +372,7 @@ updates: commit-message: prefix: chore groups: - bedrock-agent-fastapi-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -375,7 +383,7 @@ updates: commit-message: prefix: chore groups: - bedrock-agent-fastapi-zip-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -386,7 +394,7 @@ updates: commit-message: prefix: chore groups: - datadog-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -397,7 +405,7 @@ updates: commit-message: prefix: chore groups: - fastapi-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -408,7 +416,7 @@ updates: commit-message: prefix: chore groups: - fastapi-backend-only-response-streaming-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -419,7 +427,7 @@ updates: commit-message: prefix: chore groups: - fastapi-background-tasks-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -430,7 +438,7 @@ updates: commit-message: prefix: chore groups: - fastapi-response-streaming-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -441,7 +449,7 @@ updates: commit-message: prefix: chore groups: - fastapi-response-streaming-lmi-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -452,7 +460,7 @@ updates: commit-message: prefix: chore groups: - fastapi-response-streaming-zip-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -463,7 +471,7 @@ updates: commit-message: prefix: chore groups: - fastapi-snapstart-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -474,7 +482,7 @@ updates: commit-message: prefix: chore groups: - fastapi-snapstart-zip-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -487,7 +495,7 @@ updates: commit-message: prefix: chore groups: - fastapi-zip-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -498,7 +506,7 @@ updates: commit-message: prefix: chore groups: - fasthtml-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -509,7 +517,7 @@ updates: commit-message: prefix: chore groups: - fasthtml-response-streaming-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -520,7 +528,7 @@ updates: commit-message: prefix: chore groups: - fasthtml-response-streaming-zip-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -531,7 +539,7 @@ updates: commit-message: prefix: chore groups: - fasthtml-zip-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -542,7 +550,7 @@ updates: commit-message: prefix: chore groups: - fastmcp-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -553,7 +561,7 @@ updates: commit-message: prefix: chore groups: - fastmcp-zip-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -564,7 +572,7 @@ updates: commit-message: prefix: chore groups: - flask-pip: + security: applies-to: security-updates patterns: ["*"] - package-ecosystem: pip @@ -575,7 +583,7 @@ updates: commit-message: prefix: chore groups: - flask-zip-pip: + security: applies-to: security-updates patterns: ["*"] @@ -592,7 +600,7 @@ updates: commit-message: prefix: chore groups: - adapter-cargo: + security: applies-to: security-updates patterns: ["*"] @@ -610,6 +618,6 @@ updates: commit-message: prefix: ci groups: - github-actions: + security: applies-to: security-updates patterns: ["*"] diff --git a/.github/scripts/select-examples.sh b/.github/scripts/select-examples.sh index 563b864f..00e422bc 100755 --- a/.github/scripts/select-examples.sh +++ b/.github/scripts/select-examples.sh @@ -62,9 +62,14 @@ changed="$(git diff --name-only "$base" HEAD)" echo "Changed files:" echo "$changed" | sed 's/^/ /' -# Shared inputs every example is built against: the adapter itself, the layer -# wrapper, this workflow's own machinery. -if grep -qE '^(src/|layer/|Cargo\.toml$|Cargo\.lock$|\.github/workflows/examples\.yaml$|\.github/scripts/|\.github/example-matrix\.json$)' <<<"$changed"; then +# Shared inputs every example is built against: the adapter itself, the layer wrapper, +# this workflow, and the two scripts every test job actually runs. +# +# Named individually rather than as .github/scripts/, which now also holds +# dependabot-automerge.sh and check-example-config.sh — neither of which any example is +# built against, and matching the whole directory meant a one-line fix to the auto-merge +# script rebuilt and booted all eighteen entries. +if grep -qE '^(src/|layer/|Cargo\.toml$|Cargo\.lock$|\.github/workflows/examples\.yaml$|\.github/scripts/verify-http\.sh$|\.github/scripts/select-examples\.sh$|\.github/example-matrix\.json$)' <<<"$changed"; then echo "A shared path changed: verifying every example." emit_all exit 0 diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index 503a5aee..b735a057 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -12,7 +12,9 @@ on: # would land unverified and surface later on an unrelated examples pull # request. Including them makes the workflow verify its own changes. - ".github/workflows/examples.yaml" - - ".github/scripts/**" + - ".github/scripts/select-examples.sh" + - ".github/scripts/verify-http.sh" + - ".github/scripts/check-example-config.sh" - ".github/example-matrix.json" - ".github/dependabot.yml" push: @@ -101,7 +103,14 @@ jobs: exit 1 fi + # Only when something will use it. The artifact exists for the test jobs, so building + # it when `select` chose nothing is several minutes of runner time for nobody. build-layer: + needs: [select] + if: >- + needs.select.outputs.image != '[]' || + needs.select.outputs.zip != '[]' || + needs.select.outputs.stream != '[]' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 From 8a35cb2421da39778cdde6e4a9b2268af6171def Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 21:16:06 +0000 Subject: [PATCH 16/23] ci: fix two ways the drift guard could lie, and refresh a stale header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from the eleventh review on #844, plus one found while auditing. git ls-files output is now NUL-separated. `.stdout.split()` splits on any whitespace, and git prints a path containing a space verbatim, so "examples/x/my app/package.json" was torn into fragments and the tail one derived a directory of "/app" — reported as an unclaimed manifest that no entry could ever claim. Reproduced: the guard failed naming `npm: /app`; it now names `npm: /examples/space test/my app`, which is the truth. -z also disables git's C-style quoting of non-ASCII paths, which would corrupt the derived directory the same way. Latent today, but these are contributed demo apps and validate now runs on every examples pull request, so a false failure there would block all of them. Glob directory values are matched rather than compared as strings. A glob is a supported form of the key, and while this config avoids them because Dependabot refuses several entries per ecosystem it cannot prove are non-overlapping, that reasoning does not extend to a single-entry ecosystem — so a maintainer may well write one. Reproduced with `/examples/sinatra/*/src`: the covered manifest was reported as unclaimed *and* the pattern as stale, two contradictory errors on a config Dependabot accepts. Matching is segment-aware rather than fnmatch over the whole path: `*` and `?` stay inside a segment and `**` spans several, so a single `*` cannot silently span two directory levels and pass drift this check exists to catch. Both directions now use it: a manifest is unclaimed when no pattern for its ecosystem covers it, and a pattern is stale when it covers no manifest. Also refreshed the selector's header comment, which still described only the verify-everything fail-safes and predated both the select-nothing path and the merge-ref base resolution. Nine drift-guard states exercised: clean tree, a spaced path, a glob that covers, a glob that covers nothing, a `**` glob, a single `*` that must not span two segments, the singular `directory` key, a non-zero limit, and restored. Plus the merge script's twenty paths, zero mismatches, and the selector's three selection paths. --- .github/scripts/check-example-config.sh | 69 +++++++++++++++++++++++-- .github/scripts/select-examples.sh | 16 ++++-- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/.github/scripts/check-example-config.sh b/.github/scripts/check-example-config.sh index 3b8c3790..36ddad61 100755 --- a/.github/scripts/check-example-config.sh +++ b/.github/scripts/check-example-config.sh @@ -63,9 +63,21 @@ MANIFESTS = { "composer": ["composer.json"], } -tracked = subprocess.run( - ["git", "ls-files", "examples"], capture_output=True, text=True, check=True -).stdout.split() +# -z with a NUL split, not .split(): git ls-files prints a path containing a space +# verbatim, so whitespace splitting tears "examples/x/my app/package.json" into fragments +# and the tail one yields a directory ("/app") that no entry can ever claim — validate +# failing on a correct config. -z also turns off git's C-style quoting of non-ASCII +# paths, which would corrupt the derived directory the same way. +tracked = [ + path + for path in subprocess.run( + ["git", "ls-files", "-z", "examples"], + capture_output=True, + text=True, + check=True, + ).stdout.split("\0") + if path +] # (ecosystem, directory) pairs the tree actually contains. found = set() @@ -112,7 +124,48 @@ for update in config["updates"]: problems.append(f"{where}: needs `open-pull-requests-limit: 0`, or version " "updates come back on for it.") -unclaimed = sorted(found - configured) + +def directory_matches(pattern, directory): + """Whether a `directories` value covers a directory. + + Globs are a supported form of the key. This config avoids them only because + Dependabot refuses several entries for one ecosystem when it cannot prove they do not + overlap — which does not apply to a single-entry ecosystem, so a maintainer may well + write one. Comparing the values as literal strings reported the covered manifests as + unclaimed *and* the pattern as stale, both wrong at once. + + `*` and `?` stay within one path segment and `**` spans several, matching Dependabot's + globbing rather than fnmatch's, which would let `*` cross a `/` and so pass over + exactly the drift this check exists to catch. + """ + if not any(character in pattern for character in "*?["): + return pattern == directory + + parts = pattern.strip("/").split("/") + segments = directory.strip("/").split("/") + + def walk(p, s): + while p < len(parts): + if parts[p] == "**": + if p + 1 == len(parts): + return True + return any(walk(p + 1, k) for k in range(s, len(segments) + 1)) + if s >= len(segments) or not fnmatch.fnmatch(segments[s], parts[p]): + return False + p, s = p + 1, s + 1 + return s == len(segments) + + return walk(0, 0) + + +unclaimed = sorted( + (ecosystem, directory) + for ecosystem, directory in found + if not any( + eco == ecosystem and directory_matches(pattern, directory) + for eco, pattern in configured + ) +) if unclaimed: problems.append( "These manifests have no matching entry in %s, so Dependabot will open one pull\n" @@ -123,7 +176,13 @@ if unclaimed: # The reverse direction, limited to examples/: an entry for a directory that no longer # has a manifest is dead config, and usually means an example was renamed. stale = sorted( - (eco, d) for eco, d in configured - found if d.startswith("/examples/") + (ecosystem, pattern) + for ecosystem, pattern in configured + if pattern.startswith("/examples/") + and not any( + eco == ecosystem and directory_matches(pattern, directory) + for eco, directory in found + ) ) if stale: problems.append( diff --git a/.github/scripts/select-examples.sh b/.github/scripts/select-examples.sh index 00e422bc..a42369e5 100755 --- a/.github/scripts/select-examples.sh +++ b/.github/scripts/select-examples.sh @@ -7,11 +7,21 @@ # deno-zip, so verifying all 18 matrix entries for it burns runners for no signal. # With ~70 open Dependabot PRs against the examples that cost dominates CI. # -# Fails safe: anything this script cannot resolve confidently — no base commit, a -# base commit not present locally, a change to shared code — verifies everything. +# Three outcomes, in order of confidence: +# +# verify everything — no base commit (a push or a manual run), a base commit this +# clone does not have, or a change to a shared input every example is built against. +# verify the examples in the diff — the normal pull request case. +# verify nothing — the diff is empty, or touches nothing under examples/. The +# `if: ... != '[]'` guards in examples.yaml skip the test jobs, and the auto-merge +# gate refuses a run in which the changed example's own job did not succeed. +# +# The diff base comes from the merge ref's first parent, not from BASE_SHA, for the +# reason recorded below. # # Inputs: -# BASE_SHA base commit to diff against; empty means "verify everything" +# BASE_SHA base commit from the event payload; empty means "verify everything". +# Only used when HEAD is not a merge ref. # GITHUB_OUTPUT set by Actions set -euo pipefail From b1670c3a1b33d003ef27047ffe6e8d6c12c4e841 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 21:46:46 +0000 Subject: [PATCH 17/23] ci: merge after approval, not auto-merge on green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main is governed by a ruleset, not classic branch protection — which is why my earlier check of branches/main/protection returned 404 and I wrongly concluded the branch was unprotected. The ruleset has required_approving_review_count: 1, require_code_owner_review: true and zero bypass actors, and .github/CODEOWNERS assigns `*` to @aws/aws-lambda-tooling. Every Dependabot pull request is therefore BLOCKED with reviewDecision=REVIEW_REQUIRED: #842 BLOCKED REVIEW_REQUIRED MERGEABLE #841 BLOCKED REVIEW_REQUIRED MERGEABLE #839 BLOCKED REVIEW_REQUIRED MERGEABLE So the workflow could never have merged anything, and worse, it hid that: BLOCKED was lumped in with conflicts and reported as "most likely a sibling update landed first", so the hourly sweep would have skipped every pull request forever with a diagnosis that was simply wrong. Three changes, no governance change. No bypass actor is added and no approval is forged: a bot approval cannot satisfy a code-owner requirement anyway, and whether CI should be allowed to merge without review is the code owners' call, not this branch's. * reviewDecision is now a gate, checked last so that reaching it means the pull request is example-only, verified at its current head, and green. A pull request waiting on review is reported as exactly that, which turns the sweep's job summary into a worklist of "verified, waiting only on you". * BLOCKED is classified separately from DIRTY/BEHIND/DRAFT/UNKNOWN and names the ruleset as the cause. * The workflow is renamed Dependabot Merge and its header, the script's header and the pull request description say merge-after-approval rather than auto-merge. The script's own rollup exclusion is updated to match the new name. Twelve paths exercised: the four reviewDecision states, the three post-merge failure classifications under an approval, and the five earlier gates still firing ahead of the review check. --- .github/scripts/dependabot-automerge.sh | 45 ++++++++++++++++++--- .github/workflows/dependabot-automerge.yaml | 14 +++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/.github/scripts/dependabot-automerge.sh b/.github/scripts/dependabot-automerge.sh index a233e689..4c0a87a6 100755 --- a/.github/scripts/dependabot-automerge.sh +++ b/.github/scripts/dependabot-automerge.sh @@ -1,7 +1,13 @@ #!/usr/bin/env bash # -# Merges one Dependabot pull request, if it is an example-only update that Verify -# Examples has verified at the pull request's current head. +# Merges one Dependabot pull request, if it is an example-only update that a code owner +# has approved and that Verify Examples has verified at the pull request's current head. +# +# Not auto-merge-on-green: main is governed by a ruleset requiring one code-owner approval +# with zero bypass actors, so nothing can merge without a human. What this removes is the +# second trip — approve once and the merge happens within the hour, but only if the +# verification covers the exact commit being merged, so a stale approval cannot land an +# unverified head. # # Usage: REPO= dependabot-automerge.sh # @@ -53,7 +59,7 @@ skip() { # result is not wrapped: unlike the request, it cannot fail transiently, and a parse # failure there is a real fault that should be loud. if ! pr_json=$(gh pr view "$PR" --repo "$REPO" \ - --json author,headRefOid,state,statusCheckRollup); then + --json author,headRefOid,state,reviewDecision,statusCheckRollup); then skip "could not read the pull request." fi @@ -96,7 +102,7 @@ fi not_green=$(jq -r ' .statusCheckRollup[]? - | select((.workflowName // "") != "Dependabot Auto-merge") + | select((.workflowName // "") != "Dependabot Merge") | select([((.conclusion // .state // "PENDING") | ascii_upcase)] - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0) | ((.name // .context) + " = " + (.conclusion // .state // "PENDING"))' <<<"$pr_json") @@ -158,7 +164,28 @@ if [[ -n "$unverified" ]]; then skip "in the matrix but not verified by run $run_id: $(join_list "$unverified")" fi -echo "PR #$PR is example-only and verified at $head_sha by run $run_id. Merging." +# The approval is the last gate, and it is checked here rather than earlier on purpose: +# reaching this line means the pull request is example-only, verified at its current head, +# and green. Reporting it now makes the job summary a worklist of "verified, waiting only +# on you" rather than a list of things that may also be unverified. +# +# main is governed by a ruleset (not classic branch protection, which is why +# `branches/main/protection` returns 404): one approving review, `require_code_owner_review`, +# and zero bypass actors, with .github/CODEOWNERS assigning `*` to @aws/aws-lambda-tooling. +# No token can merge past that and no bot approval can satisfy it, so this workflow merges +# after a human approves — it does not approve on anyone's behalf. +review=$(jq -r '.reviewDecision // ""' <<<"$pr_json") +case "$review" in + APPROVED) ;; + CHANGES_REQUESTED) + skip "verified at ${head_sha:0:8} by run $run_id, but a reviewer requested changes." + ;; + *) + skip "verified at ${head_sha:0:8} by run $run_id — waiting for a code-owner approval (@aws/aws-lambda-tooling)." + ;; +esac + +echo "PR #$PR is example-only, verified at $head_sha by run $run_id, and approved. Merging." # --match-head-commit closes the remaining window: if the branch moves between the # lookups above and this call, the API rejects the merge rather than applying it to an @@ -190,7 +217,13 @@ if [[ "$head_after" != "$head_sha" ]]; then skip "merge rejected, head moved to $head_after." fi case "$state" in - DIRTY | BLOCKED | BEHIND | DRAFT | UNKNOWN) + # BLOCKED is called out separately because it used to be lumped in with conflicts and + # reported as "a sibling update landed first", which was simply the wrong diagnosis: + # main's ruleset blocks a merge until the required review is satisfied. + BLOCKED) + skip "merge rejected, blocked by main's ruleset (review or a required check) — reviewDecision was $review." + ;; + DIRTY | BEHIND | DRAFT | UNKNOWN) skip "merge rejected, not mergeable (mergeStateStatus=$state) — most likely a sibling update landed first." ;; esac diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index d6f76f3d..12cc3a91 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -1,7 +1,13 @@ -name: Dependabot Auto-merge +name: Dependabot Merge -# Merges Dependabot pull requests that only touch example applications, once Verify -# Examples has actually verified them. The decision lives in +# Merges Dependabot pull requests that only touch example applications, once a code owner +# has approved them and Verify Examples has verified them. +# +# Deliberately not "auto-merge on green": main is governed by a ruleset with one required +# code-owner approval and zero bypass actors, so no token here can merge without a human, +# and a bot approval cannot satisfy a CODEOWNERS requirement. The value is in the other +# half — an approval no longer means "and come back later to click merge", and the merge +# only happens if the verification covers the exact commit that lands. The decision lives in # .github/scripts/dependabot-automerge.sh; this file only decides which pull requests to # offer it. # @@ -42,7 +48,7 @@ on: # mergeStateStatus branch in dependabot-automerge.sh turn the loser into a skip with a # reason rather than a red run. concurrency: - group: dependabot-automerge-${{ github.event.workflow_run.pull_requests[0].number || 'sweep' }} + group: dependabot-merge-${{ github.event.workflow_run.pull_requests[0].number || 'sweep' }} cancel-in-progress: false permissions: From f049b3ce54b09499fc8c0ad4a8f69ccc8c442938 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 22:17:15 +0000 Subject: [PATCH 18/23] ci: let the ruleset decide whether the merge is unattended MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval gate added in b1670c3 was the wrong shape. It read reviewDecision and refused unless APPROVED, which is correct only while main's ruleset lists no bypass actors — the moment this workflow's identity becomes one, the gate would keep refusing merges the ruleset would have allowed, and the fix would be to remember to delete it. The merge is now attempted unconditionally and the outcome classified, so one script is right in both worlds: unattended where a bypass actor exists, and "blocked awaiting a code-owner approval (@aws/aws-lambda-tooling), or a ruleset bypass actor for this workflow" where none does. Nothing to keep in sync with a repository setting the script cannot see. BLOCKED is still separated from DIRTY/BEHIND/DRAFT/UNKNOWN, and now splits by reviewDecision so the three cases read differently: awaiting approval, approved but some other rule unsatisfied, and changes requested. It is no longer reported as "a sibling update landed first", which was never true for it. Seven paths exercised: unapproved without bypass, approved without bypass, unapproved with bypass, approved-but-blocked, changes-requested, a sibling conflict, and an unexplained failure still exiting 1. --- .github/scripts/dependabot-automerge.sh | 61 +++++++++++++------------ 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/.github/scripts/dependabot-automerge.sh b/.github/scripts/dependabot-automerge.sh index 4c0a87a6..3fb20e21 100755 --- a/.github/scripts/dependabot-automerge.sh +++ b/.github/scripts/dependabot-automerge.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash # -# Merges one Dependabot pull request, if it is an example-only update that a code owner -# has approved and that Verify Examples has verified at the pull request's current head. +# Merges one Dependabot pull request, if it is an example-only update that Verify Examples +# has verified at the pull request's current head. # -# Not auto-merge-on-green: main is governed by a ruleset requiring one code-owner approval -# with zero bypass actors, so nothing can merge without a human. What this removes is the -# second trip — approve once and the merge happens within the hour, but only if the -# verification covers the exact commit being merged, so a stale approval cannot land an -# unverified head. +# Whether that merge is unattended depends on main's ruleset, not on this script. The +# ruleset currently requires one code-owner approval and lists no bypass actors, so the +# merge is refused until a human approves and the refusal is reported as exactly that. +# Making this workflow's identity a bypass actor turns the same code into unattended +# auto-merge, with the guards below as the only thing standing between a bump and main — +# which is why they are what they are: example-only, verified at this exact head, every +# check green. # # Usage: REPO= dependabot-automerge.sh # @@ -164,28 +166,13 @@ if [[ -n "$unverified" ]]; then skip "in the matrix but not verified by run $run_id: $(join_list "$unverified")" fi -# The approval is the last gate, and it is checked here rather than earlier on purpose: -# reaching this line means the pull request is example-only, verified at its current head, -# and green. Reporting it now makes the job summary a worklist of "verified, waiting only -# on you" rather than a list of things that may also be unverified. -# -# main is governed by a ruleset (not classic branch protection, which is why -# `branches/main/protection` returns 404): one approving review, `require_code_owner_review`, -# and zero bypass actors, with .github/CODEOWNERS assigning `*` to @aws/aws-lambda-tooling. -# No token can merge past that and no bot approval can satisfy it, so this workflow merges -# after a human approves — it does not approve on anyone's behalf. -review=$(jq -r '.reviewDecision // ""' <<<"$pr_json") -case "$review" in - APPROVED) ;; - CHANGES_REQUESTED) - skip "verified at ${head_sha:0:8} by run $run_id, but a reviewer requested changes." - ;; - *) - skip "verified at ${head_sha:0:8} by run $run_id — waiting for a code-owner approval (@aws/aws-lambda-tooling)." - ;; -esac - -echo "PR #$PR is example-only, verified at $head_sha by run $run_id, and approved. Merging." +# Deliberately no approval gate of its own: the merge is attempted and the outcome +# classified below. That way this one script behaves correctly whichever way main's +# ruleset is configured — it merges unattended where the workflow is a bypass actor, and +# reports "waiting for a code-owner approval" where it is not, with no toggle to keep in +# sync with a repository setting it cannot see. +review=$(jq -r '.reviewDecision // "NONE"' <<<"$pr_json") +echo "PR #$PR is example-only and verified at $head_sha by run $run_id (reviewDecision=$review). Merging." # --match-head-commit closes the remaining window: if the branch moves between the # lookups above and this call, the API rejects the merge rather than applying it to an @@ -221,7 +208,21 @@ case "$state" in # reported as "a sibling update landed first", which was simply the wrong diagnosis: # main's ruleset blocks a merge until the required review is satisfied. BLOCKED) - skip "merge rejected, blocked by main's ruleset (review or a required check) — reviewDecision was $review." + # main's ruleset requires one code-owner approval (.github/CODEOWNERS assigns `*` to + # @aws/aws-lambda-tooling) and lists no bypass actors, so this is the expected + # outcome until either a human approves or this workflow's identity is made a bypass + # actor. Named precisely, because it used to be reported as a sibling conflict. + case "$review" in + APPROVED) + skip "merge rejected, blocked by main's ruleset even though it is approved — a required rule is unsatisfied." + ;; + CHANGES_REQUESTED) + skip "merge rejected, a reviewer requested changes." + ;; + *) + skip "verified at ${head_sha:0:8} by run $run_id — blocked awaiting a code-owner approval (@aws/aws-lambda-tooling), or a ruleset bypass actor for this workflow." + ;; + esac ;; DIRTY | BEHIND | DRAFT | UNKNOWN) skip "merge rejected, not mergeable (mergeStateStatus=$state) — most likely a sibling update landed first." From b39624cd11e525dd7665cf2d98cbf7e7c08c9853 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Mon, 14 Sep 2026 22:21:47 +0000 Subject: [PATCH 19/23] ci: settle on merge-after-approval, and stop floating a ruleset bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments and the skip message offered "or a ruleset bypass actor for this workflow" as though the decision were still open. It is not: main's ruleset keeps its code-owner approval requirement and gains no bypass actor, because unattended merging would mean granting some identity the right to bypass code-owner review on a public repository, which is not a trade worth making for dependency bumps in demo applications. A bot approval could not satisfy a CODEOWNERS requirement anyway. Behaviour is unchanged — the merge is still attempted and the outcome classified, which is simpler than mirroring the ruleset's configuration in the script and cannot drift from it. The skip message for the ordinary case now reads as a worklist entry: "ready to merge, awaiting a code-owner approval (@aws/aws-lambda-tooling)". What the workflow is worth without unattended merging: approve once and the merge happens within the hour, but only when the verification covers the exact commit that lands, so a stale approval cannot merge an unverified head. The hourly sweep is what makes approving a batch and walking away work. Six paths re-run, all unchanged. --- .github/scripts/dependabot-automerge.sh | 34 ++++++++++----------- .github/workflows/dependabot-automerge.yaml | 14 ++++++--- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.github/scripts/dependabot-automerge.sh b/.github/scripts/dependabot-automerge.sh index 3fb20e21..20bb0904 100755 --- a/.github/scripts/dependabot-automerge.sh +++ b/.github/scripts/dependabot-automerge.sh @@ -3,13 +3,16 @@ # Merges one Dependabot pull request, if it is an example-only update that Verify Examples # has verified at the pull request's current head. # -# Whether that merge is unattended depends on main's ruleset, not on this script. The -# ruleset currently requires one code-owner approval and lists no bypass actors, so the -# merge is refused until a human approves and the refusal is reported as exactly that. -# Making this workflow's identity a bypass actor turns the same code into unattended -# auto-merge, with the guards below as the only thing standing between a bump and main — -# which is why they are what they are: example-only, verified at this exact head, every -# check green. +# It merges after a human approves; it never merges unattended, and it never approves +# anything itself. main's ruleset requires one code-owner approval +# (.github/CODEOWNERS assigns `*` to @aws/aws-lambda-tooling) with no bypass actors, and +# that was left alone deliberately — unattended merging would mean granting some identity +# the right to bypass code-owner review on a public repository, which is not a trade worth +# making for dependency bumps in demo applications. +# +# What it removes is the second trip: approve once and the merge happens within the hour, +# but only when the verification covers the exact commit that lands, so a stale approval +# cannot merge an unverified head. # # Usage: REPO= dependabot-automerge.sh # @@ -166,11 +169,9 @@ if [[ -n "$unverified" ]]; then skip "in the matrix but not verified by run $run_id: $(join_list "$unverified")" fi -# Deliberately no approval gate of its own: the merge is attempted and the outcome -# classified below. That way this one script behaves correctly whichever way main's -# ruleset is configured — it merges unattended where the workflow is a bypass actor, and -# reports "waiting for a code-owner approval" where it is not, with no toggle to keep in -# sync with a repository setting it cannot see. +# No approval gate of its own: the merge is attempted and the outcome classified below. +# The ruleset is the authority on whether a merge may happen, so asking it is both simpler +# than mirroring its configuration here and impossible to get out of sync with. review=$(jq -r '.reviewDecision // "NONE"' <<<"$pr_json") echo "PR #$PR is example-only and verified at $head_sha by run $run_id (reviewDecision=$review). Merging." @@ -208,10 +209,9 @@ case "$state" in # reported as "a sibling update landed first", which was simply the wrong diagnosis: # main's ruleset blocks a merge until the required review is satisfied. BLOCKED) - # main's ruleset requires one code-owner approval (.github/CODEOWNERS assigns `*` to - # @aws/aws-lambda-tooling) and lists no bypass actors, so this is the expected - # outcome until either a human approves or this workflow's identity is made a bypass - # actor. Named precisely, because it used to be reported as a sibling conflict. + # The expected outcome for an unapproved pull request: main's ruleset requires a + # code-owner approval. Named precisely, because it used to be reported as a sibling + # conflict, which it never was. case "$review" in APPROVED) skip "merge rejected, blocked by main's ruleset even though it is approved — a required rule is unsatisfied." @@ -220,7 +220,7 @@ case "$state" in skip "merge rejected, a reviewer requested changes." ;; *) - skip "verified at ${head_sha:0:8} by run $run_id — blocked awaiting a code-owner approval (@aws/aws-lambda-tooling), or a ruleset bypass actor for this workflow." + skip "verified at ${head_sha:0:8} by run $run_id — ready to merge, awaiting a code-owner approval (@aws/aws-lambda-tooling)." ;; esac ;; diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index 12cc3a91..6a713f03 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -3,11 +3,15 @@ name: Dependabot Merge # Merges Dependabot pull requests that only touch example applications, once a code owner # has approved them and Verify Examples has verified them. # -# Deliberately not "auto-merge on green": main is governed by a ruleset with one required -# code-owner approval and zero bypass actors, so no token here can merge without a human, -# and a bot approval cannot satisfy a CODEOWNERS requirement. The value is in the other -# half — an approval no longer means "and come back later to click merge", and the merge -# only happens if the verification covers the exact commit that lands. The decision lives in +# Deliberately not "auto-merge on green". main's ruleset requires one code-owner approval +# and has no bypass actors, and that was a considered choice rather than an obstacle: +# unattended merging would mean giving some identity the right to bypass code-owner review +# on a public repository, which is not worth it for dependency bumps in demo apps. A bot +# approval could not satisfy a CODEOWNERS requirement anyway. +# +# The value is the other half — an approval no longer means "and come back later to click +# merge", and the merge only happens if the verification covers the exact commit that +# lands. The decision lives in # .github/scripts/dependabot-automerge.sh; this file only decides which pull requests to # offer it. # From fb69e5db7f3f9d063d5de26913a8ff6378643e74 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 15 Sep 2026 02:35:02 +0000 Subject: [PATCH 20/23] ci: drop the merge automation, leave Dependabot PRs to their owners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes .github/workflows/dependabot-automerge.yaml and .github/scripts/dependabot-automerge.sh, 372 lines and an hourly cron. Unattended merging was never available: main's ruleset requires one code-owner approval with zero bypass actors, and granting some identity the right to bypass code-owner review on a public repository is not a trade worth making for dependency bumps in demo applications. What was left after that decision merged an approved pull request without a second visit — real, but not worth 372 lines and a workflow holding contents: write, when a code owner is already in the loop and one click away. So Verify Examples does what its name says and nothing more: it fails a Dependabot pull request when the examples it touches do not build and boot, and a code owner merges. Everything that carried the weight stays — the grouped config that turns 69 per-advisory pull requests into one per example app, the selector that verifies only the examples a pull request touches, the drift guard that keeps both hand-maintained lists honest, and examples-verified as the single result a reviewer reads before merging. Also cleans up the twelve comments across four files that described the merge gate as the reason for a rule, since it no longer exists: the selector's "select nothing" contract now rests on examples-verified treating a skipped job as a pass, and the drift guard's matrix-name check on the selector choosing an example whose directory is gone. Suites re-run after the removal: the drift guard's clean tree plus a covering glob, a missing applies-to, and an unclaimed spaced manifest; the selector's no-base, empty-diff and single-example paths. --- .github/dependabot.yml | 33 ++- .github/scripts/check-example-config.sh | 6 +- .github/scripts/dependabot-automerge.sh | 233 -------------------- .github/scripts/select-examples.sh | 16 +- .github/workflows/dependabot-automerge.yaml | 139 ------------ .github/workflows/examples.yaml | 16 +- 6 files changed, 34 insertions(+), 409 deletions(-) delete mode 100755 .github/scripts/dependabot-automerge.sh delete mode 100644 .github/workflows/dependabot-automerge.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7b5fc4ea..27138349 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,8 @@ # Dependabot configuration for the example applications. # # The adapter itself (Cargo.toml at the repo root) has an entry for its commit message -# only. Version updates stay off and the auto-merge workflow refuses anything outside -# examples/, so its dependencies are still reviewed by hand. +# only. Version updates stay off, and every pull request here is reviewed and merged by +# hand, so its dependencies keep the scrutiny they had. # # Why this file exists: without it, Dependabot opens one pull request per advisory per # manifest. That produced 69 open PRs, thirteen of them against the single lockfile in @@ -12,13 +12,12 @@ # Why one entry per example rather than one glob entry per ecosystem: `directories` with # a glob plus a group produces a single pull request spanning every matching directory — # the "Bump the npm group across 17 directories with 100 updates" form, of which there -# are hundreds of thousands in the wild. That would be worse here on two counts. A bad -# bump in one example would block the security fixes for every other example in the -# group, and .github/workflows/dependabot-automerge.yaml refuses any pull request -# touching an example that .github/example-matrix.json does not build and boot — most of -# them — so a cross-directory pull request would essentially never auto-merge. One entry -# per example makes "one pull request per example app" a property of the config instead -# of an assumption about Dependabot's grouping behavior. +# are hundreds of thousands in the wild. That would be worse here: a bad bump in one +# example would block the security fixes for every other example in the group, and one +# red matrix job would make a pull request spanning a dozen examples unreviewable — you +# could not tell which bump broke which app without reading the logs. One entry per +# example makes "one pull request per example app" a property of the config instead of an +# assumption about Dependabot's grouping behavior. # # Why the directories are spelled out rather than globbed: Dependabot rejects a config # whose entries for one ecosystem it cannot prove have non-overlapping directories, and @@ -41,10 +40,10 @@ # Every group is named `security` rather than after its example, because Dependabot # builds the commit header from the group name and the directory, and commitlint caps the # header at 120 characters. `fastapi-backend-only-response-streaming-pip` against its own -# 53-character directory produced a 137-character header — Commit Lint red, auto-merge -# refusing on "checks are not all green", and a hand amend needed for exactly the -# examples with the longest names. The name only has to be unique within its entry; the -# directory in the header is what distinguishes the pull requests. +# 53-character directory produced a 137-character header — a red Commit Lint check and a +# hand amend needed for exactly the examples with the longest names. The name only has to +# be unique within its entry; the directory in the header is what distinguishes the pull +# requests. version: 2 updates: # bundler @@ -588,10 +587,10 @@ updates: patterns: ["*"] # cargo, the adapter itself rather than an example. Listed for the commit message - # only: the limit keeps version updates off, and dependabot-automerge.sh refuses - # anything touching files outside examples/, so an advisory here still gets a hand - # review — it just arrives with a header Commit Lint accepts. Pre-emptive: today's six - # open rust alerts are all in examples/rust-*-zip, which the entries above cover. + # only: the limit keeps version updates off, and an advisory here gets the same hand + # review as any other change to the adapter — it just arrives with a header Commit Lint + # accepts. Pre-emptive: today's six open rust alerts are all in examples/rust-*-zip, + # which the entries above cover. - package-ecosystem: cargo directories: ["/"] schedule: diff --git a/.github/scripts/check-example-config.sh b/.github/scripts/check-example-config.sh index 36ddad61..991785a1 100755 --- a/.github/scripts/check-example-config.sh +++ b/.github/scripts/check-example-config.sh @@ -16,9 +16,9 @@ # examples/fastmcp and examples/sveltekit-ssr-zip are recent evidence that examples get # added regularly. # -# A stale matrix name is quieter but worse: dependabot-automerge.sh treats a changed -# example as covered when the matrix names it, so a renamed or deleted example would be -# credited with coverage it does not have. +# A stale matrix name is quieter: the selector would keep choosing an example that no +# longer exists, and its job would fail on a missing working directory rather than on +# anything to do with the change under review. set -euo pipefail cd "$(dirname "$0")/../.." diff --git a/.github/scripts/dependabot-automerge.sh b/.github/scripts/dependabot-automerge.sh deleted file mode 100755 index 20bb0904..00000000 --- a/.github/scripts/dependabot-automerge.sh +++ /dev/null @@ -1,233 +0,0 @@ -#!/usr/bin/env bash -# -# Merges one Dependabot pull request, if it is an example-only update that Verify Examples -# has verified at the pull request's current head. -# -# It merges after a human approves; it never merges unattended, and it never approves -# anything itself. main's ruleset requires one code-owner approval -# (.github/CODEOWNERS assigns `*` to @aws/aws-lambda-tooling) with no bypass actors, and -# that was left alone deliberately — unattended merging would mean granting some identity -# the right to bypass code-owner review on a public repository, which is not a trade worth -# making for dependency bumps in demo applications. -# -# What it removes is the second trip: approve once and the merge happens within the hour, -# but only when the verification covers the exact commit that lands, so a stale approval -# cannot merge an unverified head. -# -# Usage: REPO= dependabot-automerge.sh -# -# Two triggers call this (see ../workflows/dependabot-automerge.yaml): a completed -# Verify Examples run, and an hourly sweep. The sweep exists because the checks on a -# pull request settle in arbitrary order — this gate used to be evaluated exactly once, -# when Verify Examples finished, so a pull request whose other checks were still running -# at that instant was skipped and never looked at again. Nothing else would have -# re-triggered it: Dependabot only pushes a branch when it rebases or recreates it, so -# an idle pull request could wait indefinitely. -# -# The run is resolved here rather than taken from an event payload, so both triggers -# behave identically and the verified commit is always the head that would be merged. -# -# Every not-yet or unresolvable condition exits 0 with a reason, and records it in the -# job summary so a skipped merge is visible instead of buried in a log. That includes a -# failed API call: refusing is the safe outcome, and GITHUB_TOKEN's 1,000 requests per -# hour are shared with every other workflow in the repository, so a transient 403 or 5xx -# is not remote. The one deliberate exception is at the very end — a merge that fails -# for no discoverable reason. -# -# The order of the checks is also the order of cost. The cheap, permanent reasons come -# first, so a pull request that can never merge (an example with no build-and-boot -# coverage — most of them) costs two API calls per sweep rather than four. -set -euo pipefail - -PR="${1:?usage: dependabot-automerge.sh }" -: "${REPO:?REPO must be set}" - -# The matrix on disk, from the checkout the workflow pinned to the default branch. The -# API would return the same content — the sibling selector resolves it the same way — -# but not over the network, and not once per pull request per sweep. -MATRIX="$(dirname "$0")/../example-matrix.json" - -# Joins a multi-line list onto one line for a message, without a trailing separator. -join_list() { - tr '\n' ' ' <<<"$1" | sed 's/ *$//' -} - -skip() { - echo "PR #$PR: $*" - if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then - echo "- **PR #$PR not merged** — $*" >>"$GITHUB_STEP_SUMMARY" - fi - exit 0 -} - -# One request for everything the pull request itself can tell us. Local jq over the -# result is not wrapped: unlike the request, it cannot fail transiently, and a parse -# failure there is a real fault that should be loud. -if ! pr_json=$(gh pr view "$PR" --repo "$REPO" \ - --json author,headRefOid,state,reviewDecision,statusCheckRollup); then - skip "could not read the pull request." -fi - -author=$(jq -r '.author.login' <<<"$pr_json") -if [[ "$author" != "app/dependabot" && "$author" != "dependabot[bot]" ]]; then - skip "authored by $author, not Dependabot." -fi - -# A closed pull request passes every other gate — its rollup is still green, its files -# are unchanged, and --match-head-commit still matches a head that never moved — so -# without this it would reach `gh pr merge` and the outcome would rest on whatever -# mergeStateStatus happens to report for a merged pull request. Reachable through the -# overlap the concurrency keys deliberately allow: a sweep run and a fast-path run on -# the same pull request, where the loser is merging something already merged. -pr_state=$(jq -r '.state' <<<"$pr_json") -if [[ "$pr_state" != "OPEN" ]]; then - skip "state is $pr_state, not OPEN." -fi - -head_sha=$(jq -r '.headRefOid' <<<"$pr_json") - -# Verify Examples is not the only check. Commit Lint runs on every pull request with no -# path filter and does go red on Dependabot pull requests (#799), and any check added -# later would otherwise be ignored here too. Anything not green — including still -# running — means leave it alone; the sweep will look again. -# -# This workflow's own run is excluded defensively: workflow_run runs do not appear in a -# pull request's check rollup today, but if that changed, its in-progress state would -# deadlock every merge. -# -# `[]?` rather than `[]`: gh emits null, not [], when the head commit has no check runs -# yet — a real few-second window every time Dependabot force-pushes a rebase — and -# iterating null aborts jq. The sweep's pre-filter already tolerates it, which is what -# makes the combination reachable: it would pass such a pull request straight to here. -# An absent rollup must never read as "everything passed": that is the shape a missing -# checks/statuses permission would produce, and `[]?` alone would swallow it and merge. -if [[ "$(jq -r '.statusCheckRollup | type' <<<"$pr_json")" != "array" ]]; then - skip "no check rollup available for $head_sha (yet)." -fi - -not_green=$(jq -r ' - .statusCheckRollup[]? - | select((.workflowName // "") != "Dependabot Merge") - | select([((.conclusion // .state // "PENDING") | ascii_upcase)] - - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0) - | ((.name // .context) + " = " + (.conclusion // .state // "PENDING"))' <<<"$pr_json") -if [[ -n "$not_green" ]]; then - skip "checks are not all green: $(join_list "$not_green")" -fi - -# Fail closed: an empty file list must never read as "nothing outside examples/". The -# API call is kept out of the pipeline so only grep's no-match status is tolerated. -if ! files=$(gh api --paginate "repos/$REPO/pulls/$PR/files" -q '.[].filename'); then - skip "could not list its files." -fi -if [[ -z "$files" ]]; then - skip "its file list came back empty." -fi - -# Scope by what the pull request changes rather than by its branch name: grouped updates -# do not reliably encode the directory in the ref. Examples are demo apps, so a bad bump -# costs a broken sample; the adapter's own dependencies, the workflows and the templates -# stay manual. -outside=$(grep -v '^examples/' <<<"$files" || true) -if [[ -n "$outside" ]]; then - skip "changes files outside examples/: $(join_list "$outside")" -fi - -changed_examples=$(cut -d/ -f2 <<<"$files" | sort -u) - -# An example with no matrix entry can never be verified, so decide that before spending -# any more calls on it. -covered=$(jq -r '[.[][].name] | unique | .[]' "$MATRIX" | sort -u) -uncovered=$(comm -23 <(printf '%s\n' "$changed_examples") <(printf '%s\n' "$covered") || true) -if [[ -n "$uncovered" ]]; then - skip "no build-and-boot coverage for: $(join_list "$uncovered") — add them to .github/example-matrix.json, or review by hand." -fi - -# A run for a superseded commit proves nothing about what would be merged, and -# Dependabot force-pushes these branches whenever it rebases. -if ! run_id=$(gh api \ - "repos/$REPO/actions/workflows/examples.yaml/runs?head_sha=$head_sha&status=success&per_page=1" \ - -q '.workflow_runs[0].id // empty'); then - skip "could not look up its Verify Examples runs." -fi -if [[ -z "$run_id" ]]; then - skip "no successful Verify Examples run for $head_sha (yet)." -fi - -# Coverage in the matrix is necessary but not sufficient: proof that an example was -# actually built and booted comes from the run's own job names, so a run predating a -# matrix change cannot be credited with verifying an example it never launched. If -# GitHub ever changes how matrix jobs are named, this stops finding matches and merges -# stop — fail-closed, and visible in the summary rather than silent. -if ! job_names=$(gh api "repos/$REPO/actions/runs/$run_id/jobs" --paginate \ - -q '.jobs[] | select(.name | startswith("test-")) | select(.conclusion == "success") | .name'); then - skip "could not list the jobs of run $run_id." -fi -verified_examples=$(sed -E 's/^test-[a-z]+ \(([^,)]+).*/\1/' <<<"$job_names" | sort -u) -unverified=$(comm -23 <(printf '%s\n' "$changed_examples") <(printf '%s\n' "$verified_examples") || true) -if [[ -n "$unverified" ]]; then - skip "in the matrix but not verified by run $run_id: $(join_list "$unverified")" -fi - -# No approval gate of its own: the merge is attempted and the outcome classified below. -# The ruleset is the authority on whether a merge may happen, so asking it is both simpler -# than mirroring its configuration here and impossible to get out of sync with. -review=$(jq -r '.reviewDecision // "NONE"' <<<"$pr_json") -echo "PR #$PR is example-only and verified at $head_sha by run $run_id (reviewDecision=$review). Merging." - -# --match-head-commit closes the remaining window: if the branch moves between the -# lookups above and this call, the API rejects the merge rather than applying it to an -# unverified commit. -# -# A rejection exits non-zero, which would turn this run red like a real failure. The -# common cause is benign: sibling pull requests touching one lockfile finish minutes -# apart, the first merge conflicts the rest, and GitHub has not necessarily recomputed -# mergeability yet. Distinguish that from a genuine problem — squash merges disabled, a -# missing permission — so this workflow's red/green state still means something. -if gh pr merge "$PR" --repo "$REPO" --squash --delete-branch \ - --match-head-commit "$head_sha"; then - if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then - echo "- **PR #$PR merged** — verified at \`${head_sha:0:8}\` by run $run_id" >>"$GITHUB_STEP_SUMMARY" - fi - exit 0 -fi - -if ! after=$(gh pr view "$PR" --repo "$REPO" --json headRefOid,state,mergeStateStatus); then - skip "merge failed and the pull request could not be re-read." -fi -head_after=$(jq -r '.headRefOid' <<<"$after") -state=$(jq -r '.mergeStateStatus' <<<"$after") -# The same race as above, but landing between the check and this call. -if [[ "$(jq -r '.state' <<<"$after")" != "OPEN" ]]; then - skip "merge rejected, the pull request is no longer open — something else landed it." -fi -if [[ "$head_after" != "$head_sha" ]]; then - skip "merge rejected, head moved to $head_after." -fi -case "$state" in - # BLOCKED is called out separately because it used to be lumped in with conflicts and - # reported as "a sibling update landed first", which was simply the wrong diagnosis: - # main's ruleset blocks a merge until the required review is satisfied. - BLOCKED) - # The expected outcome for an unapproved pull request: main's ruleset requires a - # code-owner approval. Named precisely, because it used to be reported as a sibling - # conflict, which it never was. - case "$review" in - APPROVED) - skip "merge rejected, blocked by main's ruleset even though it is approved — a required rule is unsatisfied." - ;; - CHANGES_REQUESTED) - skip "merge rejected, a reviewer requested changes." - ;; - *) - skip "verified at ${head_sha:0:8} by run $run_id — ready to merge, awaiting a code-owner approval (@aws/aws-lambda-tooling)." - ;; - esac - ;; - DIRTY | BEHIND | DRAFT | UNKNOWN) - skip "merge rejected, not mergeable (mergeStateStatus=$state) — most likely a sibling update landed first." - ;; -esac -echo "PR #$PR: unexpected merge failure (mergeStateStatus=$state)." -echo "Nothing explains it, so failing loudly rather than hiding it." -exit 1 diff --git a/.github/scripts/select-examples.sh b/.github/scripts/select-examples.sh index a42369e5..512d689c 100755 --- a/.github/scripts/select-examples.sh +++ b/.github/scripts/select-examples.sh @@ -13,8 +13,8 @@ # clone does not have, or a change to a shared input every example is built against. # verify the examples in the diff — the normal pull request case. # verify nothing — the diff is empty, or touches nothing under examples/. The -# `if: ... != '[]'` guards in examples.yaml skip the test jobs, and the auto-merge -# gate refuses a run in which the changed example's own job did not succeed. +# `if: ... != '[]'` guards in examples.yaml skip the test jobs, and examples-verified +# treats a skipped job as a pass, so the workflow is green with nothing to run. # # The diff base comes from the merge ref's first parent, not from BASE_SHA, for the # reason recorded below. @@ -75,10 +75,9 @@ echo "$changed" | sed 's/^/ /' # Shared inputs every example is built against: the adapter itself, the layer wrapper, # this workflow, and the two scripts every test job actually runs. # -# Named individually rather than as .github/scripts/, which now also holds -# dependabot-automerge.sh and check-example-config.sh — neither of which any example is -# built against, and matching the whole directory meant a one-line fix to the auto-merge -# script rebuilt and booted all eighteen entries. +# Named individually rather than as .github/scripts/, which also holds +# check-example-config.sh — which no example is built against, so matching the whole +# directory would rebuild and boot all eighteen entries for a change to it. if grep -qE '^(src/|layer/|Cargo\.toml$|Cargo\.lock$|\.github/workflows/examples\.yaml$|\.github/scripts/verify-http\.sh$|\.github/scripts/select-examples\.sh$|\.github/example-matrix\.json$)' <<<"$changed"; then echo "A shared path changed: verifying every example." emit_all @@ -92,9 +91,8 @@ example_paths="$(grep -oE '^examples/[^/]+' <<<"$changed" || true)" # Reachable with an empty diff: a stale pull request whose change already landed # through a duplicate (#804 and #811 carry an identical update set), or a re-run after -# the commit merged. "Select nothing" is the documented contract here, not "fail" — -# the `if: ... != '[]'` guards in examples.yaml skip the test jobs, and the auto-merge -# workflow refuses a run with no successful test job. +# the commit merged. "Select nothing" is the documented contract here, not "fail" — the +# `if: ... != '[]'` guards in examples.yaml skip the test jobs and the workflow is green. if [[ -z "$example_paths" ]]; then echo "No example changed: nothing to verify." for kind in image zip stream; do diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml deleted file mode 100644 index 6a713f03..00000000 --- a/.github/workflows/dependabot-automerge.yaml +++ /dev/null @@ -1,139 +0,0 @@ -name: Dependabot Merge - -# Merges Dependabot pull requests that only touch example applications, once a code owner -# has approved them and Verify Examples has verified them. -# -# Deliberately not "auto-merge on green". main's ruleset requires one code-owner approval -# and has no bypass actors, and that was a considered choice rather than an obstacle: -# unattended merging would mean giving some identity the right to bypass code-owner review -# on a public repository, which is not worth it for dependency bumps in demo apps. A bot -# approval could not satisfy a CODEOWNERS requirement anyway. -# -# The value is the other half — an approval no longer means "and come back later to click -# merge", and the merge only happens if the verification covers the exact commit that -# lands. The decision lives in -# .github/scripts/dependabot-automerge.sh; this file only decides which pull requests to -# offer it. -# -# Two triggers, deliberately: -# -# workflow_run — the fast path. A pull request that is already green elsewhere merges -# within seconds of its verification finishing. -# schedule — the catch-all. Checks on a pull request settle in arbitrary order, so -# Verify Examples can finish while Commit Lint or CodeQL is still running. With only -# the first trigger, such a pull request was skipped and never reconsidered, because -# nothing else re-triggers this workflow: Dependabot pushes a branch only when it -# rebases or recreates it, so an idle pull request could wait indefinitely. -# -# Not `gh pr merge --auto`: auto-merge is gated on the repository's *required* status -# checks, and Verify Examples is path-filtered to examples/**, so requiring its result -# would never report on a source-only pull request and would block it forever. -# -# Requires no branch protection and no repository settings. -on: - workflow_run: - workflows: ["Verify Examples"] - types: - - completed - schedule: - # Hourly, off the hour to avoid the busiest minute. - - cron: "17 * * * *" - workflow_dispatch: - -# Keyed per pull request, not one group for everything. Only one run per group may be -# pending, and a new arrival cancels the pending one — so with a single group, a burst of -# Verify Examples runs finishing together (the normal case, since Dependabot opens its -# security pull requests in batches) would cancel each other's queued fast-path runs -# until only the last survived, leaving the rest to the hourly sweep and filling the run -# history with cancellations that read like failures. -# -# The cost is that a sweep run and a fast-path run can now overlap on the same pull -# request. That is handled where it actually has to be: --match-head-commit plus the -# mergeStateStatus branch in dependabot-automerge.sh turn the loser into a skip with a -# reason rather than a red run. -concurrency: - group: dependabot-merge-${{ github.event.workflow_run.pull_requests[0].number || 'sweep' }} - cancel-in-progress: false - -permissions: - contents: write - pull-requests: write - # Required to list a run's jobs and to look a run up by head SHA. Declaring a - # permissions block sets every scope not named here to none, so without this the - # Actions API returns 403. Same reason commitlint-comment.yaml declares it. - actions: read - # statusCheckRollup is the gate both this file and dependabot-automerge.sh depend on, - # and it is made of Checks resources (CheckRun nodes) and commit statuses - # (StatusContext nodes). Without these two the query either errors — making the whole - # workflow a no-op that looks healthy — or comes back empty, which would read as "all - # checks green" and merge past a red Commit Lint. The script also refuses an absent - # rollup outright, so neither failure can be mistaken for success. - checks: read - statuses: read - -jobs: - merge: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - with: - # Pinned to the default branch on purpose. This job holds a write token, so it - # must never run a script from a pull request's head — the ref is not derived - # from the event under any trigger. - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - name: Merge eligible Dependabot pull requests - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - # Empty for schedule and manual runs, which sweep every open Dependabot PR. - TRIGGERED_PR: ${{ github.event.workflow_run.pull_requests[0].number }} - run: | - set -euo pipefail - - if [[ -n "${TRIGGERED_PR:-}" ]]; then - prs="$TRIGGERED_PR" - elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then - # A run with no associated pull request: a push to main, or a fork. - echo "Triggering run has no pull request; nothing to do." - exit 0 - else - # One request covers the whole queue, so the sweep can drop the pull - # requests that obviously cannot merge — conflicted, or a check red or - # pending — before spending API calls on them in the script. With ~70 open - # Dependabot pull requests that is the difference between a cheap hourly - # sweep and one that eats the token's hourly budget. - # - # Only CONFLICTING is excluded on mergeability, not "anything but - # MERGEABLE": GitHub reports UNKNOWN while it recomputes, and dropping those - # could skip a pull request forever. - # - # Fail-soft like every lookup in the script: an hourly red run that means - # nothing is worse than a quiet skip, and the next sweep retries anyway. - if ! prs=$(gh pr list --repo "$REPO" --author app/dependabot --state open \ - --limit 200 --json number,mergeable,statusCheckRollup -q ' - .[] - | select(.mergeable != "CONFLICTING") - | select([.statusCheckRollup[]? - | select([((.conclusion // .state // "PENDING") | ascii_upcase)] - - ["SUCCESS", "SKIPPED", "NEUTRAL"] | length > 0)] - | length == 0) - | .number'); then - echo "Could not list open Dependabot pull requests; the next sweep retries." - exit 0 - fi - fi - - if [[ -z "$prs" ]]; then - echo "No open Dependabot pull requests." - exit 0 - fi - - # One bad pull request must not stop the sweep from considering the rest, so - # collect the status instead of letting set -e abort the loop. - rc=0 - for pr in $prs; do - .github/scripts/dependabot-automerge.sh "$pr" || rc=1 - done - exit $rc diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index b735a057..1f93e340 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -412,18 +412,18 @@ jobs: docker rm -f stream-app 2>/dev/null || true kill $APP_PID 2>/dev/null || true - # One aggregate result for the whole workflow, so a reviewer (and the auto-merge - # workflow) has a single thing to look at: the matrix jobs' names embed their - # parameters ("test-zip (deno-zip, /, success, 8000)") and vanish entirely when - # `select` filters an example out, so there is no stable per-example name to read. + # One aggregate result for the whole workflow, so a reviewer has a single thing to look + # at before merging: the matrix jobs' names embed their parameters + # ("test-zip (deno-zip, /, success, 8000)") and vanish entirely when `select` filters an + # example out, so there is no stable per-example name to read. # # `skipped` counts as a pass — that is what a filtered-out matrix means. `failure` # and `cancelled` do not. # - # Note if you ever make this a required check in branch protection: this workflow is - # path-filtered to examples/**, so it never runs on a source-only pull request and - # the check would never report there, blocking the pull request indefinitely. That - # is why Dependabot auto-merge keys off this workflow's completed run instead. + # Note if you ever make this a required check in main's ruleset: this workflow is + # path-filtered, so it never runs on a pull request that touches none of those paths and + # the check would never report there, blocking that pull request indefinitely. Making it + # required needs a companion job reporting the same name on the complementary paths. examples-verified: if: always() needs: [select, validate, build-layer, test-image, test-zip, test-stream] From 0316c219d263dcc0890235224bd5fa5951e55b0c Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 15 Sep 2026 02:46:20 +0000 Subject: [PATCH 21/23] ci: fail on a missing matrix key, assert the prefix, say what was verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review on fb69e5d. emit_all wrote `null` for a missing key. `jq -c '.stream'` prints the literal null and exits 0, so a renamed top-level key in example-matrix.json wrote `stream=null`, which `!= '[]'` reads as truthy — test-stream would start and die in fromJSON('null') pointing at the workflow rather than the matrix file. The asymmetry is what made it easy to miss: the selection loop already fails loudly on the same input, because `.$kind[]` over null is a jq error, so only the verify-everything path — every push to main — degraded silently. Confirmed: emit_all exited 0 having written `stream=null`, the selection loop exits 5. It now asserts the key with `has` and exits 5 too. The drift guard now asserts commit-message.prefix, the third load-bearing key and the one already paid for: without it Dependabot writes "bump from x to y", which has no conventional type, and Commit Lint runs on every pull request with no path filter (#799). The accepted types are parsed out of commitlint.config.js rather than copied, so the check cannot disagree with the linter that actually runs; an unparseable file downgrades the assertion to "a prefix is set" instead of inventing a list. Reading the list matters here because the github-actions entry deliberately uses `ci`, which an equality check against `chore` would have rejected. examples-verified now says which of two very different things its green means. The matrix covers 18 of the ~46 examples dependabot.yml claims, so for most Dependabot pull requests every matrix filters to empty, the test jobs skip, and the aggregate goes green having built and booted nothing. Failing instead would block those examples permanently, so it is surfaced instead: the selector emits a ::warning:: naming the changed examples no matrix entry covers, and the aggregate logs either "Built and booted: " or "No example was built or booted", with the same line in the job summary. A reviewer reading one green check had no way to tell the difference, which is the same property that justifies the job existing — the per-example job names vanish when the matrix is filtered. Exercised: emit_all on a renamed key exits 5 writing no bad value downstream; the prefix assertion catches a missing prefix and a bogus one while accepting `ci`; the aggregate reports both cases and still exits 1 on a real failure. --- .github/scripts/check-example-config.sh | 32 +++++++++++++++++++++++++ .github/scripts/select-examples.sh | 27 ++++++++++++++++++++- .github/workflows/examples.yaml | 21 +++++++++++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/.github/scripts/check-example-config.sh b/.github/scripts/check-example-config.sh index 991785a1..17808bb2 100755 --- a/.github/scripts/check-example-config.sh +++ b/.github/scripts/check-example-config.sh @@ -29,6 +29,7 @@ MATRIX=.github/example-matrix.json python3 - "$DEPENDABOT" "$MATRIX" <<'PY' import fnmatch import json +import re import subprocess import sys @@ -90,6 +91,18 @@ for path in tracked: if any(fnmatch.fnmatch(parts[-1], pattern) for pattern in patterns): found.add((ecosystem, directory)) +# commitlint.config.js is the source of truth for the accepted types; parsed rather than +# duplicated so this check cannot drift from the linter. An unparseable file yields an +# empty set, which downgrades the assertion to "a prefix is set". +COMMITLINT_TYPES = set() +try: + config_js = open("commitlint.config.js").read() + enum = re.search(r"['\"]type-enum['\"]\s*:\s*\[[^\[]*\[(.*?)\]", config_js, re.S) + if enum: + COMMITLINT_TYPES = set(re.findall(r"['\"]([a-z]+)['\"]", enum.group(1))) +except OSError: + pass + configured = set() problems = [] config = yaml.safe_load(open(dependabot_path)) @@ -124,6 +137,25 @@ for update in config["updates"]: problems.append(f"{where}: needs `open-pull-requests-limit: 0`, or version " "updates come back on for it.") + # The third load-bearing key, and the one this repository has already paid for: + # without a prefix Dependabot writes "bump from x to y", which has no + # conventional type, and Commit Lint runs on every pull request with no path filter. + # #799 is the evidence. Same shape of regression as the two above — the entry looks + # fine here and the bill arrives weeks later on a pull request nobody wrote. + # + # The accepted types are read from commitlint.config.js rather than copied, so this + # cannot disagree with the linter that actually runs. If that file's shape changes the + # list comes back empty and the assertion falls back to "a prefix is set", which is + # the part that matters; a wrong-but-present prefix would then be caught by Commit + # Lint on the pull request that adds the entry. + prefix = (update.get("commit-message") or {}).get("prefix") + if not prefix: + problems.append(f"{where}: needs `commit-message.prefix`, or Commit Lint rejects " + "the header Dependabot generates.") + elif COMMITLINT_TYPES and prefix not in COMMITLINT_TYPES: + problems.append(f"{where}: `commit-message.prefix: {prefix}` is not one of " + f"commitlint's types ({', '.join(sorted(COMMITLINT_TYPES))}).") + def directory_matches(pattern, directory): """Whether a `directories` value covers a directory. diff --git a/.github/scripts/select-examples.sh b/.github/scripts/select-examples.sh index 512d689c..1d7880e6 100755 --- a/.github/scripts/select-examples.sh +++ b/.github/scripts/select-examples.sh @@ -35,7 +35,15 @@ MATRIX="$(dirname "$0")/../example-matrix.json" emit_all() { local kind matrix for kind in image zip stream; do - matrix="$(jq -c ".$kind" "$MATRIX")" + # `has` rather than a bare `.$kind`: jq prints the literal `null` and exits 0 for a + # missing key, so a renamed top-level key in the matrix file wrote `stream=null`, + # which `!= '[]'` reads as truthy — test-stream would start and die in + # fromJSON('null') complaining about the workflow instead of the matrix file. The + # selection loop at the bottom already fails loudly here, because `.$kind[]` over + # null is a jq error; this is the path every push to main takes. + matrix="$(jq -ce --arg kind "$kind" \ + 'if has($kind) then .[$kind] else error("example-matrix.json has no \"" + $kind + "\" key") end' \ + "$MATRIX")" echo "$kind=$matrix" >>"$GITHUB_OUTPUT" done } @@ -104,6 +112,23 @@ fi names="$(cut -d/ -f2 <<<"$example_paths" | sort -u | jq -R . | jq -sc .)" echo "Changed examples: $names" +# The matrix covers 18 of the ~46 examples dependabot.yml claims, so for most Dependabot +# pull requests every matrix comes out empty and examples-verified goes green having +# built and booted nothing. Failing instead would block those examples permanently, so +# say it out loud: a reviewer reading one green aggregate check cannot otherwise tell +# that the bump they are approving was never launched, because the per-example job names +# disappear when the matrix is filtered. +uncovered="$(jq -r --argjson names "$names" \ + '([.image, .zip, .stream] | flatten | map(.name)) as $covered + | [$names[] | select(IN($covered[]) | not)] | join(", ")' "$MATRIX")" +if [[ -n "$uncovered" ]]; then + echo "::warning::No matrix entry builds or boots: $uncovered — this run verifies templates only for them." + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + echo "- **Not built or booted:** $uncovered (no \`.github/example-matrix.json\` entry)" \ + >>"$GITHUB_STEP_SUMMARY" + fi +fi + for kind in image zip stream; do matrix="$(jq -c --argjson names "$names" "[.$kind[] | select(.name as \$n | \$names | index(\$n))]" "$MATRIX")" echo "$kind=$matrix" diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index 1f93e340..fd8278a5 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -432,6 +432,9 @@ jobs: - name: Check the verification results env: RESULTS: ${{ join(needs.*.result, ' ') }} + IMAGE: ${{ needs.select.outputs.image }} + ZIP: ${{ needs.select.outputs.zip }} + STREAM: ${{ needs.select.outputs.stream }} run: | echo "job results: $RESULTS" for result in $RESULTS; do @@ -443,4 +446,20 @@ jobs: ;; esac done - echo "All example verification jobs passed." + + # Green is not the same claim in both cases. Most examples have no matrix entry, + # so their pull requests filter every matrix to empty, skip the test jobs, and + # arrive here green having built and booted nothing. Say which it was, because a + # reviewer reading this one check has nothing else to go on — the per-example job + # names vanish when the matrix is filtered, which is why this job exists. + verified=$(jq -rn \ + --argjson image "${IMAGE:-[]}" --argjson zip "${ZIP:-[]}" --argjson stream "${STREAM:-[]}" \ + '[$image, $zip, $stream] | flatten | map(.name) | unique | join(", ")') + if [[ -n "$verified" ]]; then + echo "Built and booted: $verified" + echo "**Verified:** $verified" >>"$GITHUB_STEP_SUMMARY" + else + echo "No example was built or booted; this run verified templates only." + echo "**No example was built or booted** — template validation only." \ + >>"$GITHUB_STEP_SUMMARY" + fi From e26d40f6eaff761beac4ebf28aa61fc8abb92592 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 15 Sep 2026 03:17:45 +0000 Subject: [PATCH 22/23] ci: assert the matrix entries' shape, and scope the policy keys to examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from review on 0316c21. The matrix moved out of the workflow, where the per-kind key requirements were at least documented in the surrounding comments, into a JSON file the guard checked one field of. It now asserts the exact key set each kind interpolates: image needs name, path and expect_body; zip adds port; stream swaps in kind. Unknown top-level kinds are reported too. Exact sets rather than minimums, because a typo is worse than an omission here. verify-http.sh skips the body assertion entirely when the expectation is empty (`[ -z "$EXPECT_BODY" ] ||`), so `expect_bodY` would have left the job green while checking only the status code — a silent weakening. A missing `port` is merely noisy by comparison: test-zip interpolates it into PORT=, the app does not listen where the verify step looks, and the run burns its 90-second deadline to fail with "expectation not met", which reads as a broken example. Only `stream` failed clearly, through the `*)` arm on its `kind`. The three key assertions also ran over the two entries the file itself describes as not examples. Everything else in the script is scoped to examples/ — `found` comes from `git ls-files examples`, the stale check filters on the prefix — so applying the grouping policy repo-wide turned an examples-drift guard into a policy lock: enabling version updates for the adapter's own crates is a normal thing to want, has nothing to do with example grouping, and failed validate with a message that did not hint at editing this script. `applies-to: security-updates` and the limit are now asserted for example entries only. commit-message.prefix stays unconditional, since Commit Lint runs on every pull request regardless of which directory the bump came from. Nine states exercised: clean tree; a zip entry missing port; a typo'd expect_body; a renamed top-level kind; a stream entry missing kind; the root cargo entry with a non-zero limit, which now passes; an example entry with a non-zero limit and one missing applies-to, which still fail; and github-actions with no prefix, which still fails. --- .github/scripts/check-example-config.sh | 64 ++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/.github/scripts/check-example-config.sh b/.github/scripts/check-example-config.sh index 17808bb2..37d03b14 100755 --- a/.github/scripts/check-example-config.sh +++ b/.github/scripts/check-example-config.sh @@ -121,19 +121,30 @@ for update in config["updates"]: where = f"{ecosystem} {directories}" - # Both keys below are load-bearing, and an entry copy-pasted without either looks - # correct here while silently reverting that example to what this file exists to - # prevent. Plain `groups` batches version updates only, so without + # The two grouping assertions below apply to example entries only. Everything else in + # this script is scoped to examples/ — `found` comes from `git ls-files examples`, the + # stale check filters on the prefix — and applying them to the root cargo and + # github-actions entries would turn an examples-drift guard into a repository-wide + # policy lock: enabling version updates for the adapter's own crates is a normal thing + # to want, has nothing to do with example grouping, and would fail validate with a + # message that does not hint at editing this script. + is_example = any(directory.startswith("/examples/") for directory in directories) + + # Load-bearing for an example: an entry copy-pasted without either key looks correct + # here while silently reverting that example to what this file exists to prevent. + # Plain `groups` batches version updates only, so without # `applies-to: security-updates` the grouping does not apply to the advisories that # are the whole point. groups = update.get("groups") or {} - if not any(g.get("applies-to") == "security-updates" for g in groups.values()): + if is_example and not any( + g.get("applies-to") == "security-updates" for g in groups.values() + ): problems.append(f"{where}: needs a group with `applies-to: security-updates`, " "or its security updates arrive one pull request per advisory.") # And a missing or non-zero limit turns routine version bumps back on for that one # example. - if update.get("open-pull-requests-limit") != 0: + if is_example and update.get("open-pull-requests-limit") != 0: problems.append(f"{where}: needs `open-pull-requests-limit: 0`, or version " "updates come back on for it.") @@ -222,8 +233,49 @@ if stale: % (dependabot_path, "\n".join(f" {eco}: {d}" for eco, d in stale)) ) +# The keys each job kind interpolates. Exact sets, not minimums, because an unexpected +# key is almost always a typo of an expected one — and a typo is worse than an omission +# here: verify-http.sh skips the body assertion entirely when the expectation is empty +# (`[ -z "$EXPECT_BODY" ] ||`), so `expect_bodY` would leave the job green while checking +# only the status code. A missing `port` is merely noisy by comparison: test-zip +# interpolates it into PORT= and the app does not listen where the verify step looks, so +# the run burns its 90-second deadline and fails with "expectation not met", which reads +# as a broken example. Only `stream` fails clearly today, via the `*)` arm on its `kind`. +MATRIX_KEYS = { + "image": {"name", "path", "expect_body"}, + "zip": {"name", "path", "expect_body", "port"}, + "stream": {"name", "kind", "path", "expect_body"}, +} + matrix = json.load(open(matrix_path)) -names = {entry["name"] for group in matrix.values() for entry in group} + +unknown_kinds = sorted(set(matrix) - set(MATRIX_KEYS)) +if unknown_kinds: + problems.append( + "%s has kinds no job consumes: %s" % (matrix_path, ", ".join(unknown_kinds)) + ) + +for kind, entries in matrix.items(): + expected = MATRIX_KEYS.get(kind) + if expected is None: + continue + for entry in entries: + absent = expected - entry.keys() + extra = entry.keys() - expected + label = entry.get("name", "") + if absent: + problems.append( + f"{matrix_path} {kind} entry {label!r} is missing " + f"{', '.join(sorted(absent))}." + ) + if extra: + problems.append( + f"{matrix_path} {kind} entry {label!r} has keys no {kind} job reads: " + f"{', '.join(sorted(extra))} — a typo of an expected key would leave the " + "assertion silently unset." + ) + +names = {entry["name"] for group in matrix.values() for entry in group if "name" in entry} import os missing = sorted(n for n in names if not os.path.isdir(os.path.join("examples", n))) From 3a96664f595096ebd9f2d0eb72b985cdc94c0bf6 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 15 Sep 2026 03:53:53 +0000 Subject: [PATCH 23/23] ci: no-renames diff, anchor example paths, assert the last two group keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes from a code review of the branch. git diff had rename detection on, which prints only the destination. `git mv examples/fasthtml/app/main.py examples/fasthtml-zip/app/main.py` therefore reported the destination alone: fasthtml was never selected, its matrix entry never ran, and the aggregate went green while the example had lost its app file. Reproduced in a scratch repository — default output one path, --no-renames output both. Now --no-renames. The example-path pattern is anchored on a trailing slash. Without it a file sitting directly under examples/ matched and became a phantom example name, yielding empty matrices and a spurious "no matrix entry builds or boots: README.md" warning. Latent — there is no such file today. The pull request selection loop now shares emit_all's has() assertion. A renamed top-level key failed there with jq's bare "Cannot iterate over null", naming neither the file nor the key, while the push path said which key was missing. Both paths now report `example-matrix.json has no "stream" key`. The drift guard asserts the last two properties dependabot.yml documents as load-bearing. `patterns: ["*"]`, because a group with patterns: ["lodash"] satisfies the applies-to assertion while leaving every other advisory for that example ungrouped. And the generated commit header's length, because the file records that a group named after its example produced a 137-character header against commitlint's 120 — nothing checked it, and the longest directory configured today leaves 11 characters of headroom. Both limits are parsed from commitlint.config.js rather than copied. That parse needed two guards of its own, learned by getting it wrong: the rule is [severity, applicability, value], so a lazy match returns the severity 2 and every header looks 118 characters over budget — it failed the clean tree until the regex took the last number. A floor of 40 now rejects an implausible parse rather than crying wolf. Also records why the push trigger's path list is deliberately shorter than the selector's shared-input set: this workflow, verify-http.sh and the matrix file are pull request triggers, so a change to them already fans out to all eighteen entries before it lands, and repeating them on push would only re-run what the pull request just ran. The two lists answer different questions. Twelve drift-guard states and five selector paths exercised. --- .github/scripts/check-example-config.sh | 40 +++++++++++++++++++++++++ .github/scripts/select-examples.sh | 20 +++++++++++-- .github/workflows/examples.yaml | 7 +++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/.github/scripts/check-example-config.sh b/.github/scripts/check-example-config.sh index 37d03b14..c328cb2a 100755 --- a/.github/scripts/check-example-config.sh +++ b/.github/scripts/check-example-config.sh @@ -94,12 +94,23 @@ for path in tracked: # commitlint.config.js is the source of truth for the accepted types; parsed rather than # duplicated so this check cannot drift from the linter. An unparseable file yields an # empty set, which downgrades the assertion to "a prefix is set". +COMMITLINT_HEADER_MAX = 120 COMMITLINT_TYPES = set() try: config_js = open("commitlint.config.js").read() enum = re.search(r"['\"]type-enum['\"]\s*:\s*\[[^\[]*\[(.*?)\]", config_js, re.S) if enum: COMMITLINT_TYPES = set(re.findall(r"['\"]([a-z]+)['\"]", enum.group(1))) + # The last number in the rule, not the first: the array is [severity, applicability, + # value], so a lazy match returns the severity (2) and every header looks over budget. + header_max = re.search( + r"['\"]header-max-length['\"]\s*:\s*\[\s*\d+\s*,\s*['\"]\w+['\"]\s*,\s*(\d+)", + config_js, + ) + # A sanity floor for the same reason: a parse that yields a severity rather than a + # length would fail every entry, and a guard that cries wolf is worse than no guard. + if header_max and int(header_max.group(1)) >= 40: + COMMITLINT_HEADER_MAX = int(header_max.group(1)) except OSError: pass @@ -148,6 +159,35 @@ for update in config["updates"]: problems.append(f"{where}: needs `open-pull-requests-limit: 0`, or version " "updates come back on for it.") + # `patterns` is the other half of the grouping claim: a group with + # patterns: ["lodash"] satisfies the applies-to assertion above while leaving every + # other advisory for that example ungrouped, which is the state this file exists to + # prevent. + if is_example: + for group_name, group in groups.items(): + if group.get("applies-to") != "security-updates": + continue + if group.get("patterns") != ["*"]: + problems.append( + f"{where}: group {group_name!r} needs `patterns: [\"*\"]`, or " + "advisories outside the pattern arrive one pull request each." + ) + + # dependabot.yml records why every group is named `security`: the commit + # header is built from the group name and the directory, and commitlint caps + # it at 120. A group named after its example produced 137. Nothing checked + # that, so the next example could reintroduce it — headroom is 11 characters + # at the longest directory configured today. + for directory in directories: + header = (f"chore(deps): bump the {group_name} group in {directory} " + "with 5 updates") + if len(header) > COMMITLINT_HEADER_MAX: + problems.append( + f"{where}: group {group_name!r} makes a " + f"{len(header)}-character commit header for {directory}, over " + f"commitlint's {COMMITLINT_HEADER_MAX}; use a shorter group name." + ) + # The third load-bearing key, and the one this repository has already paid for: # without a prefix Dependabot writes "bump from x to y", which has no # conventional type, and Commit Lint runs on every pull request with no path filter. diff --git a/.github/scripts/select-examples.sh b/.github/scripts/select-examples.sh index 1d7880e6..6ce3b1db 100755 --- a/.github/scripts/select-examples.sh +++ b/.github/scripts/select-examples.sh @@ -76,7 +76,12 @@ else emit_all exit 0 fi -changed="$(git diff --name-only "$base" HEAD)" +# --no-renames: rename detection is on by default and prints only the destination, so +# `git mv examples/fasthtml/app/main.py examples/fasthtml-zip/app/main.py` reported the +# destination alone — fasthtml was never selected, its matrix entry never ran, and the +# aggregate went green while the example had lost its app file. Reproduced in a scratch +# repository: default output one path, --no-renames output both. +changed="$(git diff --no-renames --name-only "$base" HEAD)" echo "Changed files:" echo "$changed" | sed 's/^/ /' @@ -95,7 +100,11 @@ fi # examples//... -> . grep exits 1 when nothing matches, which pipefail # would turn into an unexplained failure of this script — so tolerate that one status, # and only that one, by keeping grep out of the pipeline below. -example_paths="$(grep -oE '^examples/[^/]+' <<<"$changed" || true)" +# The trailing slash matters: without it a file sitting directly under examples/ (a +# README, say) matches and becomes a phantom example name, producing empty matrices and a +# spurious "no matrix entry builds or boots: README.md" warning. There is no such file +# today, so this is latent. +example_paths="$(grep -oE '^examples/[^/]+/' <<<"$changed" || true)" # Reachable with an empty diff: a stale pull request whose change already landed # through a duplicate (#804 and #811 carry an identical update set), or a re-run after @@ -130,7 +139,12 @@ if [[ -n "$uncovered" ]]; then fi for kind in image zip stream; do - matrix="$(jq -c --argjson names "$names" "[.$kind[] | select(.name as \$n | \$names | index(\$n))]" "$MATRIX")" + # Same has() assertion as emit_all: without it a renamed top-level key fails here with + # jq's bare "Cannot iterate over null", naming neither the file nor the key, while the + # push path says which key is missing. Loud is not the same as diagnostic. + matrix="$(jq -c --argjson names "$names" --arg kind "$kind" \ + 'if has($kind) | not then error("example-matrix.json has no \"" + $kind + "\" key") else + [.[$kind][] | select(.name as $n | $names | index($n))] end' "$MATRIX")" echo "$kind=$matrix" echo "$kind=$matrix" >>"$GITHUB_OUTPUT" done diff --git a/.github/workflows/examples.yaml b/.github/workflows/examples.yaml index fd8278a5..7a423a1c 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -33,6 +33,13 @@ on: # all eighteen matrix entries on every source pull request. Adapter changes are # verified against the examples on push to main (above), and the selector's shared-path # rule still applies to a pull request that touches both source and examples. + # + # The push list is deliberately shorter than the selector's shared-input set, which also + # names this workflow, verify-http.sh and the matrix file. Those three are pull request + # triggers, so a change to any of them already fans out to all eighteen entries before it + # lands; repeating them here would only re-run on main what the pull request just ran. + # The two lists answer different questions — what starts a run, versus what forces a run + # to cover everything. workflow_dispatch: permissions: