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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions .github/scripts/dependency_policy.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Read-only Dependabot provenance check; successful CI decides update eligibility."""
"""Verify bot or repository-owner provenance without granting automatic merge."""

import json
import os
Expand All @@ -9,24 +9,72 @@
def verified_bot_commits(commits, head):
return bool(commits) and commits[-1].get("sha") == head and all(
(commit.get("author") or {}).get("login") == "dependabot[bot]"
and commit.get("commit", {}).get("verification", {}).get("verified") is True
and verified_web_commit(commit)
for commit in commits
)


def verified_web_commit(commit):
verification = commit.get("commit", {}).get("verification", {})
return (
(commit.get("committer") or {}).get("login") == "web-flow"
and verification.get("verified") is True
and verification.get("reason") == "valid"
)


def verified_dependency_commits(commits, head, owner):
"""Allow an owner to adopt a bot PR; the write controller stays bot-only."""
if not commits or commits[-1].get("sha") != head:
return False
owner_is_user = (
owner.get("type") == "User"
and type(owner.get("id")) is int
and owner["id"] > 0
and isinstance(owner.get("login"), str)
and bool(owner["login"])
)
for commit in commits:
if not verified_web_commit(commit):
return False
author = commit.get("author") or {}
if author.get("login") == "dependabot[bot]":
continue
if not (
owner_is_user
and type(author.get("id")) is int
and author["id"] == owner["id"]
and author.get("login") == owner["login"]
):
return False
return True


def main():
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
pr = event["pull_request"]
repo = event["repository"]["full_name"]
repository = event["repository"]
repository_id = repository.get("id")
if not (
type(repository_id) is int
and repository_id > 0
and (pr.get("head", {}).get("repo") or {}).get("id") == repository_id
and (pr.get("base", {}).get("repo") or {}).get("id") == repository_id
):
raise SystemExit("Dependency batches must use branches in this repository.")
repo = repository["full_name"]
endpoint = f"repos/{repo}/pulls/{pr['number']}/commits?per_page=100"
pages = json.loads(subprocess.check_output(
["gh", "api", endpoint, "--paginate", "--slurp"], text=True
))
commits = [commit for page in pages for commit in page]
if not verified_bot_commits(commits, pr["head"]["sha"]):
raise SystemExit("The checked batch must contain only verified Dependabot "
if not verified_dependency_commits(commits, pr["head"]["sha"],
repository.get("owner") or {}):
raise SystemExit("The checked batch must contain only GitHub-verified "
"Dependabot or repository-owner "
"commits and still match the event's head commit.")
print("Dependabot provenance verified. All update types use the required CI gates.")
print("Dependency provenance verified. Owner-authored changes remain excluded "
"from the bot-only automatic merge controller.")


if __name__ == "__main__":
Expand Down
95 changes: 91 additions & 4 deletions .github/scripts/test_dependency_policy.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import unittest

from dependency_policy import verified_bot_commits
from dependency_policy import main, verified_bot_commits, verified_dependency_commits
from unittest.mock import patch
import json
import os
import tempfile
from pathlib import Path


def commit(sha="head", author="dependabot[bot]", verified=True):
return {"sha": sha, "author": {"login": author},
"commit": {"verification": {"verified": verified}}}
def commit(sha="head", author="dependabot[bot]", verified=True, author_id=49699333,
committer="web-flow", reason="valid"):
return {"sha": sha, "author": {"login": author, "id": author_id},
"committer": {"login": committer},
"commit": {"verification": {"verified": verified, "reason": reason}}}


OWNER = {"login": "qqrm", "id": 4681325, "type": "User"}


def owner_commit(**kwargs):
return commit(author="qqrm", author_id=4681325, **kwargs)


class PolicyTests(unittest.TestCase):
Expand Down Expand Up @@ -35,6 +49,79 @@ def test_missing_provenance_fails_closed(self):
{"author": {"login": "dependabot[bot]"}}]:
self.assertFalse(verified_bot_commits([incomplete, commit()], "head"))

def test_signed_owner_can_adopt_without_becoming_bot_eligible(self):
for batch in [[owner_commit()], [commit("first"), owner_commit()]]:
self.assertTrue(verified_dependency_commits(batch, "head", OWNER))
self.assertFalse(verified_bot_commits(batch, "head"))

def test_owner_login_cannot_substitute_for_numeric_identity(self):
wrong_id = commit(author="qqrm", author_id=123)
self.assertFalse(verified_dependency_commits([wrong_id], "head", OWNER))
wrong_login = commit(author="contributor", author_id=4681325)
self.assertFalse(verified_dependency_commits([wrong_login], "head", OWNER))

def test_unsigned_or_non_github_owner_commit_is_rejected(self):
for options in [{"verified": False}, {"verified": "true"},
{"reason": "unknown_key"}, {"committer": "qqrm"}]:
with self.subTest(options=options):
self.assertFalse(verified_dependency_commits(
[owner_commit(**options)], "head", OWNER))

def test_owner_signature_cannot_cover_an_untrusted_earlier_commit(self):
self.assertFalse(verified_dependency_commits(
[commit("first", author="contributor"), owner_commit()], "head", OWNER))
self.assertFalse(verified_dependency_commits(
[commit("first", verified=False), owner_commit()], "head", OWNER))

def test_owner_provenance_requires_exact_nonempty_batch(self):
self.assertFalse(verified_dependency_commits([], "head", OWNER))
self.assertFalse(verified_dependency_commits([owner_commit()], "changed", OWNER))

def test_organization_missing_or_boolean_owner_id_is_not_a_user(self):
for owner in [{}, dict(OWNER, type="Organization"), dict(OWNER, id=True),
dict(OWNER, id=0), dict(OWNER, login="")]:
self.assertFalse(verified_dependency_commits([owner_commit()], "head", owner))

def test_entrypoint_rejects_foreign_or_deleted_head_repository_before_api(self):
for head_repo in [{"id": 2}, None, {}]:
event = {"repository": {"id": 1, "full_name": "qqrm/repo", "owner": OWNER},
"pull_request": {"head": {"repo": head_repo},
"base": {"repo": {"id": 1}}}}
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "event.json"
path.write_text(json.dumps(event))
with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(path)}), \
patch("dependency_policy.subprocess.check_output") as api:
with self.assertRaises(SystemExit):
main()
api.assert_not_called()

def test_entrypoint_checks_every_api_page_against_event_head(self):
event = {"repository": {"id": 1, "full_name": "qqrm/repo", "owner": OWNER},
"pull_request": {"number": 449, "head": {"repo": {"id": 1}, "sha": "head"},
"base": {"repo": {"id": 1}}}}
pages = [[commit("first", author="contributor")], [owner_commit()]]
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "event.json"
path.write_text(json.dumps(event))
with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(path)}), \
patch("dependency_policy.subprocess.check_output", return_value=json.dumps(pages)):
with self.assertRaises(SystemExit):
main()

def test_entrypoint_accepts_the_owner_batch_for_ci_only(self):
event = {"repository": {"id": 1, "full_name": "qqrm/repo", "owner": OWNER},
"pull_request": {"number": 449, "head": {"repo": {"id": 1}, "sha": "head"},
"base": {"repo": {"id": 1}}}}
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "event.json"
path.write_text(json.dumps(event))
with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(path)}), \
patch("dependency_policy.subprocess.check_output", return_value=json.dumps([[owner_commit()]])), \
patch("builtins.print") as output:
main()
self.assertIn("excluded", output.call_args.args[0])


if __name__ == "__main__":
unittest.main()
41 changes: 31 additions & 10 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,42 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: qqrm/webgpu-candles
ref: 1b79e9df3d64542d367a1291273a48dff5ce57a6
ref: a7fdf26716e5874a353e0ca164eb8306cb240b16
path: _perf_reference
persist-credentials: false
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
with:
toolchain: '1.99.0'
targets: wasm32-unknown-unknown
- name: Read the locked WASM runner version
- name: Read both locked WASM runner versions
id: bindgen
run: |
python3 - <<'PY'
import os
import re
import tomllib
from pathlib import Path
packages = tomllib.loads(Path('Cargo.lock').read_text())['package']
versions = {p['version'] for p in packages if p['name'] == 'wasm-bindgen'}
assert len(versions) == 1, versions
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f'version={versions.pop()}\n')
for label, root in [('reference', '_perf_reference'), ('candidate', '.')]:
packages = tomllib.loads((Path(root) / 'Cargo.lock').read_text())['package']
versions = {p['version'] for p in packages if p['name'] == 'wasm-bindgen'}
assert len(versions) == 1, versions
version = versions.pop()
assert re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', version), version
output.write(f'{label}={version}\n')
PY
- uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26
with:
tool: wasm-bindgen@${{ steps.bindgen.outputs.version }}
tool: wasm-bindgen@${{ steps.bindgen.outputs.reference }}
fallback: none
- name: Preserve the reference runner before installing the candidate version
run: install -m 755 "$(command -v wasm-bindgen-test-runner)" "$RUNNER_TEMP/reference-wasm-bindgen-test-runner"
- uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26
with:
tool: wasm-bindgen@${{ steps.bindgen.outputs.candidate }}
fallback: none
- name: Preserve the candidate runner
run: install -m 755 "$(command -v wasm-bindgen-test-runner)" "$RUNNER_TEMP/candidate-wasm-bindgen-test-runner"
- uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2
id: chrome
with:
Expand All @@ -52,6 +64,8 @@ jobs:
- name: Select fixed software WebGPU browser and record environment
env:
CHROME_BINARY: ${{ steps.chrome.outputs.chrome-path }}
REFERENCE_BINDGEN: ${{ steps.bindgen.outputs.reference }}
CANDIDATE_BINDGEN: ${{ steps.bindgen.outputs.candidate }}
TESTED_SHA: ${{ github.sha }}
TESTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
EVENT_NAME: ${{ github.event_name }}
Expand All @@ -67,6 +81,9 @@ jobs:
harness = Path('tests/performance_limit.rs').read_bytes()
reference_harness = Path('_perf_reference/tests/performance_limit.rs').read_bytes()
assert harness == reference_harness, 'Workload changed: review and update the pinned measurement reference'
# Only the reviewed lifecycle adapters differ across the reactive API versions.
assert hashlib.sha256(Path('tests/performance_context/mod.rs').read_bytes()).hexdigest() == 'aeba625e37fb7749388aed4035ba10c948a3b9db5aae2e8455de6aa87fde4b8b', 'Candidate adapter changed: review required'
assert hashlib.sha256(Path('_perf_reference/tests/performance_context/mod.rs').read_bytes()).hexdigest() == '8dcece705cc7bf14ce86282d7c0d08b097d2615eec6971620cb6aceaecc54b84', 'Reference adapter changed: review required'
config = json.loads(Path('webdriver-perf.json').read_text())
config['goog:chromeOptions']['binary'] = os.environ['CHROME_BINARY']
profile = json.dumps(config)
Expand All @@ -76,7 +93,7 @@ jobs:
Path('_perf_reference/webdriver.json').write_text(profile)
reference_sha = subprocess.check_output(
['git', '-C', '_perf_reference', 'rev-parse', 'HEAD'], text=True).strip()
assert reference_sha == '1b79e9df3d64542d367a1291273a48dff5ce57a6'
assert reference_sha == 'a7fdf26716e5874a353e0ca164eb8306cb240b16'
metadata = {
'tested_sha': os.environ['TESTED_SHA'],
'tested_head_sha': os.environ['TESTED_HEAD_SHA'],
Expand All @@ -92,13 +109,17 @@ jobs:
'reference_sha': reference_sha,
'harness_sha256': hashlib.sha256(harness).hexdigest(),
'browser_profile_sha256': hashlib.sha256(profile.encode()).hexdigest(),
'wasm_bindgen_reference': os.environ['REFERENCE_BINDGEN'],
'wasm_bindgen_candidate': os.environ['CANDIDATE_BINDGEN'],
'reference_context_sha256': hashlib.sha256(Path('_perf_reference/tests/performance_context/mod.rs').read_bytes()).hexdigest(),
'candidate_context_sha256': hashlib.sha256(Path('tests/performance_context/mod.rs').read_bytes()).hexdigest(),
'comparison': 'candidate versus pinned reference measured on this same runner',
}
Path('perf-environment.json').write_text(json.dumps(metadata, indent=2) + '\n')
PY
- name: Measure the pinned reference on this runner
env:
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: ${{ runner.temp }}/reference-wasm-bindgen-test-runner
CHROMEDRIVER: ${{ steps.chrome.outputs.chromedriver-path }}
WASM_BINDGEN_TEST_ONLY_WEB: '1'
WASM_BINDGEN_TEST_TIMEOUT: '180'
Expand All @@ -108,7 +129,7 @@ jobs:
python3 scripts/parse_perf_log.py reference-perf.log reference_result.json
- name: Measure completed frames for each deterministic dataset
env:
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: ${{ runner.temp }}/candidate-wasm-bindgen-test-runner
CHROMEDRIVER: ${{ steps.chrome.outputs.chromedriver-path }}
WASM_BINDGEN_TEST_ONLY_WEB: '1'
WASM_BINDGEN_TEST_TIMEOUT: '180'
Expand Down
46 changes: 37 additions & 9 deletions .github/workflows/dependabot-automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,27 @@ on: # zizmor: ignore[dangerous-triggers]
types: [completed]

# Metadata only: never check out PR code or consume its artifacts here.
permissions:
contents: write
pull-requests: write
checks: read
permissions: {}

concurrency:
group: dependabot-automerge-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false

jobs:
automerge:
permissions:
contents: write
pull-requests: write
checks: read
actions: read
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Enable native auto-merge for the checked Dependabot commit
- name: Merge the checked Dependabot commit
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
Expand All @@ -38,17 +40,43 @@ jobs:
echo 'No associated pull request; nothing to merge.'
exit 0
fi
info="$(gh pr view "$pr" --repo "$REPO" --json author,isCrossRepository,headRefOid,baseRefName,isDraft,state,statusCheckRollup)"
info="$(gh pr view "$pr" --repo "$REPO" --json author,isCrossRepository,headRefOid,baseRefName,isDraft,state,mergeStateStatus,statusCheckRollup)"
if ! jq -e --arg sha "$CHECKED_SHA" '
(.author.login == "app/dependabot" or .author.login == "dependabot[bot]") and
.isCrossRepository == false and .baseRefName == "main" and
.state == "OPEN" and .isDraft == false and .headRefOid == $sha and
.mergeStateStatus == "CLEAN" and
(["ci-gate", "workflow-security-gate"] -
[.statusCheckRollup[] | select(.status == "COMPLETED" and .conclusion == "SUCCESS") | .name] | length == 0)
[.statusCheckRollup[] | select(.status == "COMPLETED" and .conclusion == "SUCCESS") | .name] | length == 0) and
all(.statusCheckRollup[];
if .__typename == "CheckRun" then
.status == "COMPLETED" and
(.conclusion == "SUCCESS" or .conclusion == "NEUTRAL" or .conclusion == "SKIPPED")
elif .__typename == "StatusContext" then .state == "SUCCESS"
else false end)
' <<< "$info" >/dev/null; then
echo 'The current Dependabot commit has not passed both required gates.'
exit 0
fi
# Native protection enforces checks and an up-to-date branch, without reviews.
gh pr merge "$pr" --repo "$REPO" --auto --squash \
# Metadata only: an owner may adopt the PR, but only bot commits auto-merge.
commits="$(gh api "repos/$REPO/pulls/$pr/commits?per_page=100" --paginate --slurp)"
if ! jq -e --arg sha "$CHECKED_SHA" '
add | length > 0 and .[-1].sha == $sha and
all(.[]; .author.login == "dependabot[bot]" and
.committer.login == "web-flow" and .commit.verification.verified == true and
.commit.verification.reason == "valid")
' <<< "$commits" >/dev/null; then
echo 'This batch is not exclusively signed by Dependabot; no automatic merge.'
exit 0
fi
base_sha="$(gh api "repos/$REPO/git/ref/heads/main" --jq .object.sha)"
comparison="$(gh api "repos/$REPO/compare/$base_sha...$CHECKED_SHA")"
if ! jq -e --arg base "$base_sha" '
.behind_by == 0 and .merge_base_commit.sha == $base
' <<< "$comparison" >/dev/null; then
echo 'The batch must refresh after main changed; no merge.'
exit 0
fi
# Native protection rechecks gates and current base at the merge boundary.
gh pr merge "$pr" --repo "$REPO" --squash \
--match-head-commit "$CHECKED_SHA"
Loading
Loading