id: docker-dash
namespace: company.team
description: |
dockerDash checks a Dockerfile once a week and opens a pull request
for outdated image tags. It asks Docker Hub for the latest tags,
skips anything that already has an open bump PR, and stays quiet
when everything is current.
inputs:
- id: repo_owner
type: STRING
description: who owns the repo, like kestra-io
- id: repo_name
type: STRING
description: the repo name, like kestra
- id: dockerfile_path
type: STRING
description: where the Dockerfile lives in the repo
defaults: Dockerfile
- id: base_branch
type: STRING
description: the branch the bump branch starts from
defaults: main
tasks:
- id: fetch_dockerfile
type: io.kestra.plugin.core.http.Request
description: asks GitHub for the Dockerfile
method: GET
uri: https://api.github.com/repos/{{ inputs.repo_owner }}/{{ inputs.repo_name
}}/contents/{{ inputs.dockerfile_path }}?ref={{ inputs.base_branch }}
headers:
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
Accept: "application/vnd.github+json"
- id: get_base_sha
type: io.kestra.plugin.core.http.Request
description: asks GitHub for the latest commit on the base branch, new branches
start from here
method: GET
uri: https://api.github.com/repos/{{ inputs.repo_owner }}/{{ inputs.repo_name
}}/git/ref/heads/{{ inputs.base_branch }}
headers:
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
Accept: "application/vnd.github+json"
- id: list_open_prs
type: io.kestra.plugin.core.http.Request
description: asks GitHub for the open pull requests, so it never opens a
duplicate bump PR
method: GET
uri: https://api.github.com/repos/{{ inputs.repo_owner }}/{{ inputs.repo_name
}}/pulls?state=open&per_page=100
headers:
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
Accept: "application/vnd.github+json"
- id: find_outdated
type: io.kestra.plugin.scripts.python.Script
description: reads every FROM line, checks each tag against Docker Hub, and
returns what is outdated
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.11
dependencies:
- kestra
script: |
import base64
import json
import re
import urllib.request
from datetime import datetime, timezone
from kestra import Kestra
def parse_version(v):
# compare versions as numbers, so 10.0 beats 9.0
# strip the distro suffix first, so "8-alpine3.23" -> (8,) and not (8, 23)
core = v.split('-')[0]
parts = []
for piece in core.split('.'):
num = ''
for ch in piece:
if ch.isdigit():
num += ch
else:
break
parts.append(int(num) if num else 0)
return tuple(parts)
def is_version_tag(t):
# "1.26.0" yes, "latest" no, "alpine" no
return bool(t) and t[0].isdigit()
def hub_repo(image):
# "nginx" -> "library/nginx", "myuser/img" -> "myuser/img", "ghcr.io/x" -> None
if '/' not in image:
return 'library/' + image
first = image.split('/')[0]
if '.' in first or ':' in first:
return None
return image
def parse_from(line):
# "FROM --platform=linux/amd64 nginx:1.25.3 AS builder" -> ("nginx", "1.25.3", "nginx:1.25.3")
m = re.match(r'FROM\s+(?:--[^\s]+\s+)*([^\s]+)', line, re.IGNORECASE)
if not m:
return None
ref = m.group(1).split('@')[0] # digest pins are already pinned, skip the @ part
if ':' in ref:
image, _, tag = ref.rpartition(':')
if '/' in tag: # the colon was a registry port, there is no tag
return (ref, None, ref)
else:
return (ref, None, ref)
return (image, tag, ref)
# GitHub sends the file base64-encoded, so decode it first
raw = base64.b64decode("""{{ outputs.fetch_dockerfile.body | jq(".content") | first }}""").decode()
lines = raw.splitlines()
# go through the FROM lines and keep the image and tag pairs
pins = []
skipped = []
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped.upper().startswith('FROM'):
continue
parsed = parse_from(stripped)
if not parsed:
continue
image, tag, ref = parsed
if not tag or not is_version_tag(tag):
continue # "latest", "alpine" or digest pin, nothing to compare
repo = hub_repo(image)
if not repo:
skipped.append({'image': image, 'reason': 'not on docker hub'})
continue # not on Docker Hub, skip it quietly
pins.append({'image': image, 'tag': tag, 'ref': ref, 'repo': repo, 'line_idx': i})
# ask Docker Hub for the latest tag of each image
outdated = []
for p in pins:
try:
url = f"https://hub.docker.com/v2/repositories/{p['repo']}/tags?page_size=100"
with urllib.request.urlopen(url, timeout=15) as r:
tags = [t['name'] for t in json.load(r)['results']]
except Exception as e:
skipped.append({'image': p['image'], 'reason': f'docker hub request failed: {type(e).__name__}'})
continue # not found or network hiccup, skip it quietly
candidates = [t for t in tags if is_version_tag(t)]
if not candidates:
continue
# highest version wins, pure numbers beat suffixed ones on ties
best = max(candidates, key=lambda t: (parse_version(t), t.replace('.', '').isdigit()))
if parse_version(best) > parse_version(p['tag']):
outdated.append({**p, 'latest': best})
# skip anything that already has an open bump PR
open_titles = json.loads("""{{ outputs.list_open_prs.body | jq("[.[].title]") | first }}""")
fresh = [o for o in outdated if not any(o['image'] in t for t in open_titles)]
bump_lines = {o['line_idx'] for o in fresh}
# write the new Dockerfile with the bumped tags
new_lines = []
for i, line in enumerate(lines):
if i in bump_lines:
o = next(o for o in fresh if o['line_idx'] == i)
line = line.replace(o['ref'], f"{o['image']}:{o['latest']}", 1)
new_lines.append(line)
new_content = base64.b64encode('\n'.join(new_lines).encode()).decode()
branch_name = datetime.now(timezone.utc).strftime('docker/bump-%Y%m%d-%H%M%S')
bump_names = ', '.join(o['image'] for o in fresh)
pr_lines = ['dockerDash found outdated image tags:', '']
for o in fresh:
pr_lines.append(f"- {o['image']}: {o['tag']} -> {o['latest']}")
pr_lines += ['', '_Opened automatically by dockerDash._']
pr_body = '\\n'.join(pr_lines)
Kestra.outputs({
'outdated': fresh,
'count': len(fresh),
'skipped': skipped,
'new_content': new_content,
'branch_name': branch_name,
'bump_names': bump_names,
'pr_body': pr_body,
})
- id: process_updates
type: io.kestra.plugin.core.flow.If
description: only does anything when something is actually outdated
condition: "{{ outputs.find_outdated.vars.count > 0 }}"
then:
- id: create_branch
type: io.kestra.plugin.core.http.Request
description: makes the bump branch from the base branch
method: POST
uri: https://api.github.com/repos/{{ inputs.repo_owner }}/{{ inputs.repo_name
}}/git/refs
headers:
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
Accept: "application/vnd.github+json"
contentType: application/json
body: |
{
"ref": "refs/heads/{{ outputs.find_outdated.vars.branch_name }}",
"sha": "{{ outputs.get_base_sha.body | jq('.object.sha') | first }}"
}
- id: update_file
type: io.kestra.plugin.core.http.Request
description: writes the bumped Dockerfile to the new branch
method: PUT
uri: https://api.github.com/repos/{{ inputs.repo_owner }}/{{ inputs.repo_name
}}/contents/{{ inputs.dockerfile_path }}
headers:
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
Accept: "application/vnd.github+json"
contentType: application/json
body: |
{
"message": "chore: bump docker images",
"content": "{{ outputs.find_outdated.vars.new_content }}",
"sha": "{{ outputs.fetch_dockerfile.body | jq('.sha') | first }}",
"branch": "{{ outputs.find_outdated.vars.branch_name }}"
}
- id: open_pr
type: io.kestra.plugin.core.http.Request
description: opens the pull request with the bumped image tags
method: POST
uri: https://api.github.com/repos/{{ inputs.repo_owner }}/{{ inputs.repo_name
}}/pulls
headers:
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
Accept: "application/vnd.github+json"
contentType: application/json
body: |
{
"title": "chore: bump {{ outputs.find_outdated.vars.bump_names }}",
"head": "{{ outputs.find_outdated.vars.branch_name }}",
"base": "{{ inputs.base_branch }}",
"body": "{{ outputs.find_outdated.vars.pr_body }}"
}
triggers:
- id: weekly
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 9 * * 3"
timezone: UTC
inputs:
repo_owner: kestra-io
repo_name: kestra
dockerfile_path: Dockerfile
base_branch: main