From 0ad2981c101d8b8166dbe82ed36020eb966d05f5 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 19 Sep 2026 18:34:14 -0600 Subject: [PATCH] Adopt the shared Dependabot auto-merge gate --- .github/dependabot.yml | 15 +-- .github/workflows/dependabot.yml | 40 +++--- scripts/engineering/SOURCE.json | 8 ++ .../engineering/helpers/dependency-policy.py | 32 +++++ scripts/engineering/helpers/merge-checked.py | 117 ++++++++++++++++++ 5 files changed, 181 insertions(+), 31 deletions(-) create mode 100644 scripts/engineering/SOURCE.json create mode 100644 scripts/engineering/helpers/dependency-policy.py create mode 100644 scripts/engineering/helpers/merge-checked.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b154457..1f74278 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,19 +6,14 @@ # has gone stale -- a tag at least reads as a version. This is the thing that # tells you. # -# Minor and patch updates are grouped: they share the same risk profile and CI -# can merge them without ceremony. Major updates are deliberately excluded, so -# Dependabot opens one reviewable pull request for each breaking-change boundary. +# No groups: the shared gate (scripts/engineering/helpers/dependency-policy.py) +# never passes a grouped update -- several dependencies cannot be judged as one +# routine change -- so a group would only sit waiting for review. One pull +# request per dependency keeps each merge attributable; major updates remain +# their own reviewable pull requests either way. version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly - groups: - actions-minor-patch: - patterns: - - "*" - update-types: - - minor - - patch diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml index 157be10..f2aa279 100644 --- a/.github/workflows/dependabot.yml +++ b/.github/workflows/dependabot.yml @@ -1,31 +1,29 @@ name: Dependabot -# Dependabot's minor and patch action updates are grouped in dependabot.yml. -# Once the required CI check passes, this marks only those low-risk updates for -# squash merge. Major updates never satisfy the condition and remain manual. +# Thin caller: the gate body is the shared reusable workflow in nicodes/cicd, +# pinned by full commit SHA. The policy scripts it runs come from this +# repository's vendored scripts/engineering/helpers/ at the pull request's +# protected base SHA -- never from the PR head. This site deploys via Vercel +# and has no cd.yml, so merged-main coverage is dispatched onto ci.yml. on: - pull_request: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] +# The called workflow can only downgrade these, never elevate them; omitting +# the block would fall back to the repository's read-only default. permissions: contents: write pull-requests: write + actions: write + checks: read + statuses: read + +concurrency: + group: dependabot-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: auto-merge: - if: github.event.pull_request.user.login == 'dependabot[bot]' - runs-on: ubuntu-latest - steps: - - name: Read Dependabot metadata - id: metadata - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Enable auto-merge for minor and patch updates - if: >- - steps.metadata.outputs.update-type == 'version-update:semver-minor' || - steps.metadata.outputs.update-type == 'version-update:semver-patch' - run: gh pr merge --auto --squash "$PR_URL" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_URL: ${{ github.event.pull_request.html_url }} + uses: nicodes/cicd/.github/workflows/dependabot.yml@fade862978fc1a2ba625d9486443aa0e6723f8bf + with: + dispatch-target: ci.yml diff --git a/scripts/engineering/SOURCE.json b/scripts/engineering/SOURCE.json new file mode 100644 index 0000000..5510614 --- /dev/null +++ b/scripts/engineering/SOURCE.json @@ -0,0 +1,8 @@ +{ + "repository": "https://github.com/nicodes/cicd", + "revision": "fade862978fc1a2ba625d9486443aa0e6723f8bf", + "files": { + "helpers/dependency-policy.py": "3a602b3f46d0e2a6dfe38328ca5741eecce57c120830bb57c484fc1da0ba0265", + "helpers/merge-checked.py": "b86a1e741697e3f0034da2d3b7decc9af06a0d9e0f23cbd2d001dc90bd4c530e" + } +} diff --git a/scripts/engineering/helpers/dependency-policy.py b/scripts/engineering/helpers/dependency-policy.py new file mode 100644 index 0000000..f75c889 --- /dev/null +++ b/scripts/engineering/helpers/dependency-policy.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Allow only an independently verified, routine single-dependency update.""" +import os +import re + +SENSITIVE = re.compile(r'clerk|pocketbase|jsonwebtoken|(?:^|[/@-])(?:auth|oauth|oidc|jwt|jose|crypto|noble|peculiar|stablelib|passport|bcrypt|scrypt|argon2|tweetnacl|libsodium|elliptic|ed25519|curve25519|rsa|openpgp|sshpk|pkijs|node-forge|firebase)(?:$|[/@-])', re.I) + + +def eligible(names, previous, new, update_type, group=''): + dependencies = [value.strip() for value in names.split(',') if value.strip()] + if group or len(dependencies) != 1 or SENSITIVE.search(dependencies[0]): + return False + if update_type not in {'version-update:semver-patch', 'version-update:semver-minor'}: + return False + versions = [re.fullmatch(r'v?(\d+)\.(\d+)\.(\d+)', value) for value in [previous, new]] + if not all(versions): + return False # Unknown versions, prereleases and digest-only updates need review. + old, current = [tuple(map(int, value.groups())) for value in versions] + if current <= old or current[0] != old[0]: + return False + if old[0] == 0 and current[1] != old[1]: + return False + return True + + +if __name__ == '__main__': + result = eligible(os.environ.get('DEPENDENCY_NAMES', ''), os.environ.get('PREVIOUS_VERSION', ''), + os.environ.get('NEW_VERSION', ''), os.environ.get('UPDATE_TYPE', ''), + os.environ.get('DEPENDENCY_GROUP', '')) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'eligible={str(result).lower()}\n') + print('Routine update eligible for the full-check merge gate' if result else 'Dependency update requires review') diff --git a/scripts/engineering/helpers/merge-checked.py b/scripts/engineering/helpers/merge-checked.py new file mode 100644 index 0000000..07960e2 --- /dev/null +++ b/scripts/engineering/helpers/merge-checked.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Merge a routine Dependabot update only after every exact-head gate succeeds.""" +import json +import os +import re +import subprocess +import time + + +def api(path, method='GET', body=None): + command = ['gh', 'api', '--method', method, '-H', 'Accept: application/vnd.github+json', + '-H', 'X-GitHub-Api-Version: 2022-11-28', path] + if body is not None: + command += ['--input', '-'] + result = subprocess.run(command, input=json.dumps(body) if body is not None else None, + text=True, capture_output=True, timeout=60, check=True) + return json.loads(result.stdout) if result.stdout.strip() else None + + +def pages(path, key=None): + # check-runs and statuses both cap pages at 100. Never treat page one as all evidence. + values = [] + for page in range(1, 101): + data = api(f'{path}{"&" if "?" in path else "?"}per_page=100&page={page}') + items = data[key] if key else data + if not isinstance(items, list): + raise ValueError('invalid paginated check response') + values.extend(items) + if len(items) < 100: + return values + raise ValueError('check pagination limit exceeded; require manual review') + + +def decision(checks, statuses, head, self_prefix, required=('Test', 'Build')): + relevant = [] + for check in checks: + if check.get('head_sha') != head: + raise ValueError('check evidence belongs to another commit') + if not check.get('details_url', '').startswith(self_prefix): + relevant.append(check) + seen = {c['name'] for c in relevant if c.get('app', {}).get('slug') == 'github-actions' + and c.get('status') == 'completed' and c.get('conclusion') == 'success'} + for check in relevant: + if check.get('status') == 'completed' and check.get('conclusion') not in ('success', 'skipped'): + raise ValueError(f'failed check: {check["name"]}') + if check.get('name') in required and check.get('conclusion') == 'skipped': + raise ValueError(f'required check was skipped: {check["name"]}') + latest = {} + for status in statuses: # REST returns newest first. + latest.setdefault(status['context'], status) + if any(status.get('state') not in ('success', 'pending') for status in latest.values()): + raise ValueError('a commit status failed') + return (set(required) <= seen and all(c.get('status') == 'completed' for c in relevant) + and all(s.get('state') == 'success' for s in latest.values())) + + +def check_identity(repo, pr, head, run): + # The merge automation may run only for the three portfolio orgs (docs/ACTIVE-PROJECTS.md). + if not re.fullmatch(r'(?:nicodes|aviorstudio|astrylogical)/[a-z0-9-]+', repo) or not pr.isdigit() or not run.isdigit() or not re.fullmatch(r'[a-f0-9]{40}', head): + raise ValueError('invalid merge identity') + + +def dispatch_candidates(target): + if target and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*\.ya?ml', target): + raise ValueError('invalid dispatch target') + return ([target] if target else []) + ['cd.yml', 'ci.yml'] + + +def dispatch(repo, candidates): + for workflow in candidates: + try: + api(f'repos/{repo}/actions/workflows/{workflow}') + except subprocess.CalledProcessError as error: + if error.stderr and '404' in error.stderr: + continue + raise + api(f'repos/{repo}/actions/workflows/{workflow}/dispatches', 'POST', {'ref': 'main'}) + return workflow + raise ValueError('no dispatchable workflow for merged main; coverage would be silently dropped') + + +def main(): + repo, pr, head, run = [os.environ[key] for key in ['GITHUB_REPOSITORY', 'PR_NUMBER', 'EXPECTED_HEAD', 'GITHUB_RUN_ID']] + check_identity(repo, pr, head, run) + candidates = dispatch_candidates(os.environ.get('DISPATCH_TARGET', '')) + prefix = f'https://github.com/{repo}/actions/runs/{run}/' + def pull(): + value = api(f'repos/{repo}/pulls/{pr}') + if value.get('state') != 'open' or value.get('draft') is not False or value['head']['sha'] != head: + raise ValueError('pull request changed or is not ready') + if value['user']['login'] != 'dependabot[bot]' or value['base']['ref'] != 'main': + raise ValueError('not a Dependabot update targeting main') + return value + deadline = time.monotonic()+5400 + while time.monotonic() < deadline: + pull() + checks = pages(f'repos/{repo}/commits/{head}/check-runs?filter=latest', 'check_runs') + statuses = pages(f'repos/{repo}/commits/{head}/statuses', None) + if decision(checks, statuses, head, prefix): + pull() # Close movement between evidence collection and the atomic SHA merge. + result = api(f'repos/{repo}/pulls/{pr}/merge', 'PUT', {'sha': head, 'merge_method': 'squash'}) + if result.get('merged') is not True or not re.fullmatch(r'[a-f0-9]{40}', result.get('sha', '')): + raise ValueError('GitHub did not confirm the merge') + merged = result['sha'] + if api(f'repos/{repo}/git/ref/heads/main')['object']['sha'] != merged: + raise ValueError('main moved before workflow dispatch; review the newer main run') + # GITHUB_TOKEN merges suppress push events. Dispatch the first existing merged gate. + workflow = dispatch(repo, candidates) + print(f'Merged checked head {head}; dispatched {workflow} for merged main {merged}') + return + print('Waiting for the complete Test and Build gate', flush=True) + time.sleep(15) + raise TimeoutError('full gate did not complete within 90 minutes; no merge performed') + + +if __name__ == '__main__': + main()