diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..27138349 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,622 @@ +# 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 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 +# examples/remix/remix-app, which then conflict with each other as soon as one lands. +# 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 — +# 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: 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 +# 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; .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 +# 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. +# +# 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 — 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 + - package-ecosystem: bundler + directories: ["/examples/sinatra/app/src"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + + # cargo + - package-ecosystem: cargo + directories: ["/examples/rust-actix-web-zip/rust_app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: cargo + directories: ["/examples/rust-axum-zip/rust_app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + + # gomod + - package-ecosystem: gomod + directories: ["/examples/gin/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: gomod + directories: ["/examples/gin-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: gomod + directories: ["/examples/go-http-zip"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + + # maven + - package-ecosystem: maven + directories: ["/examples/javalin-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: maven + directories: ["/examples/springboot/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: maven + directories: ["/examples/springboot-response-streaming-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: maven + directories: ["/examples/springboot-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + + # npm + - package-ecosystem: npm + directories: ["/examples/bun-graphql-streaming-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/bun-graphql-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + 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 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: + - "/examples/datadog-zip/expressjs/cdk" + - "/examples/datadog-zip/expressjs/lambda-asset/src" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/expressjs/app/src"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/expressjs-zip/hello-world"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/nextjs/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/nextjs-response-streaming"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/nextjs-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/remix/remix-app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/remix-zip/remix-app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/sls/nestjs"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/sqs-expressjs/app/src"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: npm + directories: ["/examples/sveltekit-ssr-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + + # nuget + - package-ecosystem: nuget + directories: ["/examples/aspnet-mvc/src"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: nuget + directories: ["/examples/aspnet-mvc-zip/src"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: nuget + directories: ["/examples/aspnet-webapi-zip/src"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + + # pip + - package-ecosystem: pip + directories: ["/examples/bedrock-agent-fastapi/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/bedrock-agent-fastapi-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/datadog/flask/lambda-asset/src"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-backend-only-response-streaming/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-background-tasks/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-response-streaming/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-response-streaming-lmi/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-response-streaming-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-snapstart/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastapi-snapstart-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: + - "/examples/fastapi-zip/app" + - "/examples/fastapi-zip/tests" + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fasthtml/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fasthtml-response-streaming/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fasthtml-response-streaming-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fasthtml-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastmcp/my_mcp_server"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/fastmcp-zip/my_mcp_server"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/flask/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + - package-ecosystem: pip + directories: ["/examples/flask-zip/app"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + + # cargo, the adapter itself rather than an example. Listed for the commit message + # 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: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: chore + groups: + security: + applies-to: security-updates + patterns: ["*"] + + # 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 + directories: ["/"] + schedule: + interval: weekly + open-pull-requests-limit: 0 + commit-message: + prefix: ci + groups: + security: + applies-to: security-updates + patterns: ["*"] 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/check-example-config.sh b/.github/scripts/check-example-config.sh new file mode 100755 index 00000000..c328cb2a --- /dev/null +++ b/.github/scripts/check-example-config.sh @@ -0,0 +1,336 @@ +#!/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: 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")/../.." + +DEPENDABOT=.github/dependabot.yml +MATRIX=.github/example-matrix.json + +python3 - "$DEPENDABOT" "$MATRIX" <<'PY' +import fnmatch +import json +import re +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 -> 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", "pyproject.toml", "Pipfile", "setup.py"], + "gomod": ["go.mod"], + "maven": ["pom.xml"], + "gradle": ["build.gradle", "build.gradle.kts"], + "nuget": ["*.csproj", "*.fsproj", "*.vbproj", "packages.config"], + "cargo": ["Cargo.toml"], + "bundler": ["Gemfile", "*.gemspec"], + "composer": ["composer.json"], +} + +# -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() +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)) + +# 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 + +configured = set() +problems = [] +config = yaml.safe_load(open(dependabot_path)) +for update in config["updates"]: + 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}" + + # 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 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 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.") + + # `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. + # #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. + + 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" + "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( + (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( + "These %s entries point at directories with no matching manifest:\n%s" + % (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)) + +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))) +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/select-examples.sh b/.github/scripts/select-examples.sh new file mode 100755 index 00000000..6ce3b1db --- /dev/null +++ b/.github/scripts/select-examples.sh @@ -0,0 +1,150 @@ +#!/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. +# +# 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 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. +# +# Inputs: +# 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 + +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 matrix + for kind in image zip stream; do + # `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 +} + +if [[ -z "${BASE_SHA:-}" ]]; then + echo "No base commit (push or manual run): verifying every example." + emit_all + exit 0 +fi + +# 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 +# --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/^/ /' + +# 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 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 + exit 0 +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. +# 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 +# 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 + echo "$kind=[]" >>"$GITHUB_OUTPUT" + done + exit 0 +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 + # 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 bfc4af71..7a423a1c 100644 --- a/.github/workflows/examples.yaml +++ b/.github/workflows/examples.yaml @@ -6,11 +6,40 @@ 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/select-examples.sh" + - ".github/scripts/verify-http.sh" + - ".github/scripts/check-example-config.sh" + - ".github/example-matrix.json" + - ".github/dependabot.yml" 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/**" + - "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. + # + # 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: @@ -20,6 +49,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: @@ -34,6 +85,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 @@ -50,7 +110,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 @@ -82,20 +149,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 +238,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 +349,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 +418,55 @@ 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 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 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] + runs-on: ubuntu-24.04 + steps: + - 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 + case "$result" in + success | skipped) ;; + *) + echo "A verification job reported '$result'." + exit 1 + ;; + esac + done + + # 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