Skip to content

Commit

Permalink
Merge pull request #8 from openzim/readme
Browse files Browse the repository at this point in the history
README upload support
  • Loading branch information
rgaudin authored Mar 2, 2022
2 parents 6c3fe84 + f5518d7 commit 911294b
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 4 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# v8

- now supports setting description and overview on docker.io's Hub

# v7

- now supports adding a `webhook` to call on successful push
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Build and push
uses: openzim/docker-publish-action@v7
uses: openzim/docker-publish-action@v8
with:
image-name: openzim/zimit
DOCKERIO_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}
Expand Down Expand Up @@ -60,7 +60,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Build and push
uses: openzim/docker-publish-action@v7
uses: openzim/docker-publish-action@v8
with:
image-name: openzim/zimit
registries: |
Expand Down Expand Up @@ -99,6 +99,8 @@ jobs:
| `manual-tag` | **Manual tag override**<br />Replaces `on-master` and `tag-pattern` if not empty.<br />Also triggers `:latest` if `latest-on-tag` is `true`. |
| `restrict-to` | **Don't push if action is run for a different repository**<br />Specify as `{owner}/{repository}`. |
| `webhook` | **URL to POST to after image is pushed**<br />Will receive a JSON POST request somewhat similar to Docker Hub webhook payload. |
| `repo_description` | **Text to set as repository description on docker.io's Hub (truncated to chars)**<br />If pushing to docker.io, will set this string as *repository description* on the Hub. Special value `auto` uses Github repository's description. |
| `repo_overview` | **Text (markdown) to set as repository overview on docker.io's Hub (truncated to 25KB)**<br />If pushing to docker.io, will set this string as *repository overview* on the Hub. If starting with **`file:`**, will use the content of referenced file instead. Relative to `context`. Example: `file:../welcome.md`. Special value **`auto`** will look for a `README[.md|rst]` file in context (and parents). |



Expand Down
15 changes: 15 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ inputs:
description: URL to request (POST) to after a sucessful push to registry•ies
required: false
default: ''
repo_description:
description: Text to set as repository description on docker.io (100 chars max)
required: false
repo_overview:
description: Text (markdown) to set as repository overview on docker.io (2.5MB max)
required: false

runs:
using: composite
Expand All @@ -74,6 +80,8 @@ runs:
RESTRICT_TO: ${{ inputs.restrict-to }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
WEBHOOK_URL: ${{ inputs.webhook }}
REPO_DESCRIPTION: ${{ inputs.repo_description }}
REPO_FULL_DESCRIPTION: ${{ inputs.repo_overview }}
DOCKER_BUILDX_VERSION: 0.5.1

- name: find tag
Expand Down Expand Up @@ -102,6 +110,13 @@ runs:
run: python3 $GITHUB_ACTION_PATH/docker_logout.py
shell: bash

- name: update docker.io's Hub descriptions
run: python3 $GITHUB_ACTION_PATH/update_dockerio_descriptions.py
env:
CREDENTIALS: ${{ inputs.credentials }}
if: ${{ env.SHOULD_UPDATE_DOCKERIO }}
shell: bash

- name: run webhook
run: python3 $GITHUB_ACTION_PATH/run_webhook.py
shell: bash
Expand Down
8 changes: 8 additions & 0 deletions check_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ def main():
"RESTRICT_TO",
"DOCKER_BUILDX_VERSION",
"WEBHOOK_URL",
"REPO_DESCRIPTION",
"REPO_FULL_DESCRIPTION",
"SHOULD_UPDATE_DOCKERIO",
]

# fail early if missing this required info
Expand All @@ -55,6 +58,11 @@ def main():
print("not triggered on restricted-to repo, skipping.", getenv("RESTRICT_TO"))
return 1

if "docker.io" in getenv("REGISTRIES", "").split() and (
getenv("REPO_DESCRIPTION") or getenv("REPO_FULL_DESCRIPTION")
):
os.environ["SHOULD_UPDATE_DOCKERIO"] = "1"

with open(getenv("GITHUB_ENV"), "a") as fh:
for env in required_inputs + optional_inputs:
# don't write credentials to shared env! nor don't overwrite GH ones
Expand Down
9 changes: 7 additions & 2 deletions run_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,17 @@ def run_webhook():
# concurrent requests.
# ex: 2 close-apart requests for different apps in same sloppy project raises 409
attempts = 0
while attempts < 4:
max_attempts = 3
while attempts < max_attempts + 1:
attempts += 1
try:
return do_run_webhook(payload)
except urllib.error.HTTPError as exc:
print("Unexpected {}. {} attempts remaining".format(exc, attempts))
print(
"Unexpected Error on Attempt {}/{}: {}. ".format(
attempts, max_attempts, exc
)
)
time.sleep(attempts * 30)
continue
print("Exhausted retry attempts")
Expand Down
208 changes: 208 additions & 0 deletions update_dockerio_descriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import json
import os
import pathlib
import re
import sys
import time
import urllib.error
import urllib.request
from typing import Dict, Tuple

FULLDESC_MAX_FILE_SIZE = 25000 # 25KB
DESC_MAX_CHARS = 100


def get_credentials(registry: str) -> Tuple[str, str]:
"""Username, password for a registry reading environ"""
credentials = dict(
[
[x.strip() for x in item.split("=")] if "=" in item else (item.strip(), "")
for item in os.getenv("CREDENTIALS", "").split()
]
)

prefix = registry.upper().replace(".", "")
return (
credentials.get(f"{prefix}_USERNAME", ""),
credentials.get(f"{prefix}_TOKEN", ""),
)


def get_dockerhub_jwt(username: str, password: str) -> str:
"""docker.io's Hub API JWT from logging-in with username and password"""
json_payload = json.dumps({"username": username, "password": password}).encode(
"utf-8"
)
response = urllib.request.urlopen(
urllib.request.Request(
url="https://hub.docker.com/v2/users/login",
data=json_payload,
headers={
"Content-Type": "application/json; charset=utf-8",
"Content-Length": len(json_payload),
},
method="POST",
)
)

if not response.getcode() == 200:
raise ValueError(
"Unable to login to docker.io's hub API: HTTP {}: {}".format(
response.getcode(), response.reason
)
)

try:
body = response.read()
return json.loads(body.decode("UTF-8")).get("token")
except Exception as exc:
raise ValueError(
"Unable to read hub API's response: {} -- {}".format(exc, body)
)


def get_github_description(repository: str) -> str:
"""API-provided description of a public repository on Github"""
response = urllib.request.urlopen(
urllib.request.Request(
url="https://api.github.com/repos/{}".format(
os.getenv("GITHUB_REPOSITORY")
),
headers={"Accept": "application/vnd.github.v3+json"},
)
)
if not response.getcode() == 200:
raise ValueError(
"Unable to retrieve description from Github API HTTP {}: {}".format(
response.getcode(), response.reason
)
)

try:
body = response.read()
return json.loads(body.decode("UTF-8")).get("description")
except Exception as exc:
raise ValueError(
"Unable to read Github's API's response: {} -- {}".format(exc, body)
)


def do_update_dockerio_api(payload: Dict, token: str) -> int:
json_payload = json.dumps(payload).encode("utf-8")
response = urllib.request.urlopen(
urllib.request.Request(
url="https://hub.docker.com/v2/repositories/{}/".format(
os.getenv("IMAGE_NAME")
),
data=json_payload,
headers={
"Authorization": "JWT {}".format(token),
"Content-Type": "application/json; charset=utf-8",
"Content-Length": len(json_payload),
},
method="PATCH",
)
)

if response.code >= 300:
print("Unexpected HTTP {}/{} response".format(response.code, response.msg))
print(response.read().decode("UTF-8"))
return 1

return 0


def read_overview_from(hint: str) -> str:
"""README/file content found and read from hint
should hint be a relative path prefixed with `file`:
or should hint be `auto`."""
repo_root = pathlib.Path(os.getenv("GITHUB_WORKSPACE")).resolve()
context_root = repo_root.joinpath(os.getenv("CONTEXT")).resolve()

# relative file path
if re.match(r"^file\:.+", hint):
fpath = context_root.joinpath(re.sub(r"^file\:", "", hint)).resolve()
if repo_root not in fpath.parents:
raise ValueError("Cannot access files above repo root: {}".format(fpath))

try:
with open(fpath, "r", encoding="utf-8") as fh:
return fh.read(FULLDESC_MAX_FILE_SIZE)
except Exception as exc:
raise IOError(
"Unable to read description file from {}: {}".format(fpath, exc)
)

# auto mode looks for README[.md|.rst] in context and parents
if hint == "auto":
for folder in (
([context_root] if context_root is not repo_root else [])
+ [p for p in context_root.parents if repo_root in p.parents]
+ [repo_root]
):
for suffix in (".md", ".rst", ".txt", ""):
fpath = folder.joinpath("README").with_suffix(suffix)
if fpath.exists():
print("Using README from", fpath)
try:
with open(fpath, "r", encoding="utf-8") as fh:
return fh.read(FULLDESC_MAX_FILE_SIZE)
except Exception as exc:
raise IOError(
"Unable to read description file from {}: {}".format(
fpath, exc
)
)
break


def update_dockerio_api():
description = os.getenv("REPO_DESCRIPTION")
if description == "auto":
description = get_github_description(os.getenv("GITHUB_REPOSITORY"))

full_description = os.getenv("REPO_FULL_DESCRIPTION")
if full_description and (
re.match(r"^file\:.+", full_description) or full_description == "auto"
):
full_description = read_overview_from(full_description)

jwt_token = get_dockerhub_jwt(*get_credentials("docker.io"))

payload = {}
if description is not None:
payload["description"] = description[:DESC_MAX_CHARS]
if full_description is not None:
payload["full_description"] = full_description

print("Updating docker.io's Hub API for {}…".format(os.getenv("IMAGE_NAME")))
print("---\n{}\n---".format(json.dumps(payload, indent=4)))

# allow a few attempts at updating the Hub
attempts = 0
max_attempts = 3
while attempts < max_attempts + 1:
attempts += 1
try:
return do_update_dockerio_api(payload, jwt_token)
except urllib.error.HTTPError as exc:
print(
"Unexpected Error on Attempt {}/{}: {}. ".format(
attempts, max_attempts, exc
)
)
time.sleep(attempts * 30)
continue
print("Exhausted retry attempts")
return 1


if __name__ == "__main__":
if not os.getenv("SHOULD_UPDATE_DOCKERIO"):
sys.exit(0)

if not os.getenv("REPO_DESCRIPTION") and not os.getenv("REPO_FULL_DESCRIPTION"):
print("no description, skipping.")
sys.exit(0)
sys.exit(update_dockerio_api())

0 comments on commit 911294b

Please sign in to comment.