Skip to content
Merged
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
15 changes: 5 additions & 10 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 19 additions & 21 deletions .github/workflows/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions scripts/engineering/SOURCE.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"repository": "https://github.com/nicodes/cicd",
"revision": "fade862978fc1a2ba625d9486443aa0e6723f8bf",
"files": {
"helpers/dependency-policy.py": "3a602b3f46d0e2a6dfe38328ca5741eecce57c120830bb57c484fc1da0ba0265",
"helpers/merge-checked.py": "b86a1e741697e3f0034da2d3b7decc9af06a0d9e0f23cbd2d001dc90bd4c530e"
}
}
32 changes: 32 additions & 0 deletions scripts/engineering/helpers/dependency-policy.py
Original file line number Diff line number Diff line change
@@ -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')
117 changes: 117 additions & 0 deletions scripts/engineering/helpers/merge-checked.py
Original file line number Diff line number Diff line change
@@ -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()