diff --git a/.github/workflows/check-versions.yml b/.github/workflows/check-versions.yml new file mode 100644 index 0000000..37f0f5f --- /dev/null +++ b/.github/workflows/check-versions.yml @@ -0,0 +1,240 @@ +name: check-versions + +on: + schedule: + # Run every Sunday at midnight UTC + - cron: '0 0 * * 0' + workflow_dispatch: + +jobs: + check-versions: + name: Check for new upstream versions + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 https://github.com/actions/checkout/releases/tag/v6.0.2 + with: + fetch-depth: 0 + + - name: Check and create PRs for new versions + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + UPSTREAM_CONFIG="scripts/upstream-versions.json" + COMPOSE_FILE="docker-compose.yaml" + + if [[ ! -f "$UPSTREAM_CONFIG" ]]; then + echo "ERROR: Upstream config not found: $UPSTREAM_CONFIG" + exit 1 + fi + + if [[ ! -f "$COMPOSE_FILE" ]]; then + echo "ERROR: Compose file not found: $COMPOSE_FILE" + exit 1 + fi + + PACKAGES=$(jq -r 'keys[]' "$UPSTREAM_CONFIG") + + for PACKAGE in $PACKAGES; do + echo "==========================================" + echo "Checking package: $PACKAGE" + echo "==========================================" + + COMPOSE_VAR=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].compose_var' "$UPSTREAM_CONFIG") + GHCR_IMAGE=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].ghcr_image // ""' "$UPSTREAM_CONFIG") + DOCKER_IMAGE=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].docker_image // ""' "$UPSTREAM_CONFIG") + TAG_PREFIX=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].tag_prefix // ""' "$UPSTREAM_CONFIG") + TAG_REGEX=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].tag_regex // ""' "$UPSTREAM_CONFIG") + + if [[ "$COMPOSE_VAR" == "null" || -z "$COMPOSE_VAR" ]]; then + echo " WARNING: compose_var not set for $PACKAGE, skipping" + continue + fi + + if [[ -z "$GHCR_IMAGE" && -z "$DOCKER_IMAGE" ]]; then + echo " WARNING: No image source defined for $PACKAGE, skipping" + continue + fi + + if [[ -z "$TAG_REGEX" ]]; then + TAG_REGEX='^[0-9]+(\.[0-9]+){2,3}$' + fi + + if [[ -n "$GHCR_IMAGE" ]]; then + echo " Using GHCR image: $GHCR_IMAGE" + GHCR_ORG="${GHCR_IMAGE%%/*}" + GHCR_PKG="${GHCR_IMAGE#*/}" + + UPSTREAM_TAG=$(gh api "/orgs/${GHCR_ORG}/packages/container/${GHCR_PKG}/versions" --paginate \ + --jq '.[].metadata.container.tags[]' 2>/dev/null \ + | grep -E "$TAG_REGEX" \ + | sort -V | tail -1 || echo "") + + if [[ -z "$UPSTREAM_TAG" ]]; then + echo " WARNING: Could not fetch tags from GHCR for $GHCR_IMAGE, skipping" + continue + fi + + SOURCE_REF="ghcr.io/${GHCR_IMAGE}:${UPSTREAM_TAG}" + else + echo " Using Docker Hub image: $DOCKER_IMAGE" + + UPSTREAM_TAG=$(python3 - "$DOCKER_IMAGE" "$TAG_REGEX" <<'PY' || echo "" +import json +import re +import sys +import urllib.request + +image = sys.argv[1] +regex = re.compile(sys.argv[2]) + +page = 1 +page_size = 100 +found = [] + +while True: + url = f"https://hub.docker.com/v2/repositories/{image}/tags?page={page}&page_size={page_size}" + with urllib.request.urlopen(url) as resp: + data = json.loads(resp.read().decode("utf-8")) + + for item in data.get("results", []): + tag = item.get("name", "") + if regex.match(tag): + found.append(tag) + + if not data.get("next"): + break + page += 1 + +if not found: + sys.exit(1) + +def normalize(tag): + if tag.startswith("v"): + tag = tag[1:] + parts = re.split(r"[.-]", tag) + key = [] + for part in parts: + if part.isdigit(): + key.append(int(part)) + else: + key.append(part) + return key + +found.sort(key=normalize) +print(found[-1]) +PY +) + + if [[ -z "$UPSTREAM_TAG" ]]; then + echo " WARNING: Could not fetch tags from Docker Hub for $DOCKER_IMAGE, skipping" + continue + fi + + SOURCE_REF="docker.io/${DOCKER_IMAGE}:${UPSTREAM_TAG}" + fi + + if [[ -n "$TAG_PREFIX" && "$UPSTREAM_TAG" == "$TAG_PREFIX"* ]]; then + UPSTREAM_VERSION="${UPSTREAM_TAG#${TAG_PREFIX}}" + else + UPSTREAM_VERSION="$UPSTREAM_TAG" + fi + + echo " Upstream tag: $UPSTREAM_TAG" + echo " Upstream version: $UPSTREAM_VERSION" + + LOCAL_VERSION=$(python3 - "$COMPOSE_FILE" "$COMPOSE_VAR" <<'PY' || echo "" +import re +import sys + +compose_file = sys.argv[1] +compose_var = sys.argv[2] + +pattern = re.compile(r"\$\{" + re.escape(compose_var) + r":-([^}]+)\}") +with open(compose_file, "r") as fh: + data = fh.read() + +match = pattern.search(data) +if not match: + sys.exit(1) + +print(match.group(1)) +PY +) + + if [[ -z "$LOCAL_VERSION" ]]; then + echo " WARNING: Could not find ${COMPOSE_VAR} default in $COMPOSE_FILE, skipping" + continue + fi + + echo " Local version: $LOCAL_VERSION" + + if [[ "$UPSTREAM_VERSION" == "$LOCAL_VERSION" ]]; then + echo " Already up to date" + continue + fi + + echo " New version available: $UPSTREAM_VERSION" + + # Verify image exists before updating + echo " Verifying image: $SOURCE_REF" + if ! docker manifest inspect "$SOURCE_REF" > /dev/null 2>&1; then + echo " WARNING: Image not found or not accessible: $SOURCE_REF, skipping" + continue + fi + + BRANCH_NAME="chore/${PACKAGE}-${UPSTREAM_VERSION}" + EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number' 2>/dev/null || echo "") + if [[ -n "$EXISTING_PR" ]]; then + echo " PR #$EXISTING_PR already exists for this version, skipping" + continue + fi + + echo " Updating $COMPOSE_FILE..." + if ! ./scripts/add-version.sh "$PACKAGE" "$UPSTREAM_VERSION"; then + echo " WARNING: Failed to update $COMPOSE_FILE for $PACKAGE, skipping" + git checkout -- "$COMPOSE_FILE" || true + continue + fi + + git checkout -b "$BRANCH_NAME" + git add "$COMPOSE_FILE" + git commit -m "chore: ${PACKAGE} ${UPSTREAM_VERSION}" + + echo " Pushing branch..." + git push -u origin "$BRANCH_NAME" + + echo " Creating PR..." + PR_BODY="## Summary + - Updates ${PACKAGE} to version ${UPSTREAM_VERSION} + - Source: ${SOURCE_REF} + + ## Test plan + - [ ] CI validation passes + - [ ] Manual testing if needed + + 🤖 Generated automatically by check-versions workflow" + + gh pr create \ + --title "chore: ${PACKAGE} ${UPSTREAM_VERSION}" \ + --body "$PR_BODY" \ + --base main \ + --head "$BRANCH_NAME" + + git checkout main + + echo " PR created successfully!" + done + + echo "" + echo "==========================================" + echo "Version check complete" + echo "==========================================" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..059645a --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: check-versions validate-versions add-version + +check-versions: + ./scripts/check-versions.sh + +validate-versions: + ./scripts/validate-versions.sh + +add-version: + @test -n "$(PACKAGE)" && test -n "$(VERSION)" || (echo "Usage: make add-version PACKAGE=name VERSION=1.2.3"; exit 1) + ./scripts/add-version.sh "$(PACKAGE)" "$(VERSION)" diff --git a/README.md b/README.md index 43bce54..ea54645 100644 --- a/README.md +++ b/README.md @@ -141,3 +141,31 @@ To start just the `cardano-wallet` service, which is part of the `wallet` profil ```bash docker compose --profile wallet up ``` + +## Version Maintenance + +To check for upstream image updates and refresh default versions in the compose file, run: + +```bash +./scripts/check-versions.sh +``` + +To validate that all configured compose variables exist with defaults: + +```bash +./scripts/validate-versions.sh +``` + +To update a specific service version: + +```bash +./scripts/add-version.sh cardano-node 10.6.2 +``` + +You can also use the Makefile targets: + +```bash +make check-versions +make validate-versions +make add-version PACKAGE=cardano-node VERSION=10.6.2 +``` diff --git a/scripts/add-version.sh b/scripts/add-version.sh new file mode 100755 index 0000000..5f07e58 --- /dev/null +++ b/scripts/add-version.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Updates the docker-compose default version for a package. +# Usage: ./scripts/add-version.sh +# Example: ./scripts/add-version.sh cardano-node 10.6.2 + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " + echo "Example: $0 cardano-node 10.6.2" + exit 1 +fi + +PACKAGE_NAME="$1" +NEW_VERSION="$2" +UPSTREAM_CONFIG="scripts/upstream-versions.json" +COMPOSE_FILE="docker-compose.yaml" + +if [[ ! -f "$UPSTREAM_CONFIG" ]]; then + echo "ERROR: Upstream config not found: $UPSTREAM_CONFIG" + exit 1 +fi + +if [[ ! -f "$COMPOSE_FILE" ]]; then + echo "ERROR: Compose file not found: $COMPOSE_FILE" + exit 1 +fi + +COMPOSE_VAR=$(jq -r --arg pkg "$PACKAGE_NAME" '.[$pkg].compose_var // ""' "$UPSTREAM_CONFIG") +if [[ -z "$COMPOSE_VAR" || "$COMPOSE_VAR" == "null" ]]; then + echo "ERROR: compose_var not set for $PACKAGE_NAME" + exit 1 +fi + +OLD_VERSION=$(python3 - "$COMPOSE_FILE" "$COMPOSE_VAR" <<'PY' || echo "" +import re +import sys + +compose_file = sys.argv[1] +compose_var = sys.argv[2] + +pattern = re.compile(r"\$\{" + re.escape(compose_var) + r":-([^}]+)\}") +with open(compose_file, "r") as fh: + data = fh.read() + +match = pattern.search(data) +if not match: + sys.exit(1) + +print(match.group(1)) +PY +) + +if [[ -z "$OLD_VERSION" ]]; then + echo "ERROR: Could not find ${COMPOSE_VAR} default in $COMPOSE_FILE" + exit 1 +fi + +if [[ "$OLD_VERSION" == "$NEW_VERSION" ]]; then + echo "No change: ${COMPOSE_VAR} already set to ${NEW_VERSION}" + exit 0 +fi + +if ! python3 - "$COMPOSE_FILE" "$COMPOSE_VAR" "$NEW_VERSION" <<'PY' +import re +import sys + +compose_file = sys.argv[1] +compose_var = sys.argv[2] +new_version = sys.argv[3] + +pattern = re.compile(r"(\$\{" + re.escape(compose_var) + r":-)([^}]+)(\})") +with open(compose_file, "r") as fh: + data = fh.read() + +if not pattern.search(data): + print("compose var not found", file=sys.stderr) + sys.exit(1) + +updated = pattern.sub(r"\g<1>" + new_version + r"\g<3>", data, count=1) + +with open(compose_file, "w") as fh: + fh.write(updated) +PY +then + echo "ERROR: Failed to update $COMPOSE_FILE for $PACKAGE_NAME" + exit 1 +fi + +echo "Updated ${COMPOSE_VAR}: ${OLD_VERSION} -> ${NEW_VERSION}" diff --git a/scripts/check-versions.sh b/scripts/check-versions.sh new file mode 100755 index 0000000..fa4772f --- /dev/null +++ b/scripts/check-versions.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Checks upstream image versions and updates docker-compose defaults. +# Usage: ./scripts/check-versions.sh +# Optional: DRY_RUN=1 to report changes without writing. + +set -euo pipefail + +UPSTREAM_CONFIG="scripts/upstream-versions.json" +COMPOSE_FILE="docker-compose.yaml" + +if [[ ! -f "$UPSTREAM_CONFIG" ]]; then + echo "ERROR: Upstream config not found: $UPSTREAM_CONFIG" + exit 1 +fi + +if [[ ! -f "$COMPOSE_FILE" ]]; then + echo "ERROR: Compose file not found: $COMPOSE_FILE" + exit 1 +fi + +PACKAGES=$(jq -r 'keys[]' "$UPSTREAM_CONFIG") +UPDATED=0 + +for PACKAGE in $PACKAGES; do + echo "==========================================" + echo "Checking package: $PACKAGE" + echo "==========================================" + + COMPOSE_VAR=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].compose_var' "$UPSTREAM_CONFIG") + GHCR_IMAGE=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].ghcr_image // ""' "$UPSTREAM_CONFIG") + DOCKER_IMAGE=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].docker_image // ""' "$UPSTREAM_CONFIG") + TAG_PREFIX=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].tag_prefix // ""' "$UPSTREAM_CONFIG") + TAG_REGEX=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].tag_regex // ""' "$UPSTREAM_CONFIG") + + if [[ "$COMPOSE_VAR" == "null" || -z "$COMPOSE_VAR" ]]; then + echo " WARNING: compose_var not set for $PACKAGE, skipping" + continue + fi + + if [[ -z "$GHCR_IMAGE" && -z "$DOCKER_IMAGE" ]]; then + echo " WARNING: No image source defined for $PACKAGE, skipping" + continue + fi + + if [[ -z "$TAG_REGEX" ]]; then + TAG_REGEX='^[0-9]+(\.[0-9]+){2,3}$' + fi + + if [[ -n "$GHCR_IMAGE" ]]; then + echo " Using GHCR image: $GHCR_IMAGE" + GHCR_ORG="${GHCR_IMAGE%%/*}" + GHCR_PKG="${GHCR_IMAGE#*/}" + + UPSTREAM_TAG=$(gh api "/orgs/${GHCR_ORG}/packages/container/${GHCR_PKG}/versions" --paginate \ + --jq '.[].metadata.container.tags[]' 2>/dev/null \ + | grep -E "$TAG_REGEX" \ + | sort -V | tail -1 || echo "") + + if [[ -z "$UPSTREAM_TAG" ]]; then + echo " WARNING: Could not fetch tags from GHCR for $GHCR_IMAGE, skipping" + continue + fi + else + echo " Using Docker Hub image: $DOCKER_IMAGE" + + UPSTREAM_TAG=$(python3 - "$DOCKER_IMAGE" "$TAG_REGEX" <<'PY' || echo "" +import json +import re +import sys +import urllib.request + +image = sys.argv[1] +regex = re.compile(sys.argv[2]) + +page = 1 +page_size = 100 +found = [] + +while True: + url = f"https://hub.docker.com/v2/repositories/{image}/tags?page={page}&page_size={page_size}" + with urllib.request.urlopen(url) as resp: + data = json.loads(resp.read().decode("utf-8")) + + for item in data.get("results", []): + tag = item.get("name", "") + if regex.match(tag): + found.append(tag) + + if not data.get("next"): + break + page += 1 + +if not found: + sys.exit(1) + +def normalize(tag): + if tag.startswith("v"): + tag = tag[1:] + parts = re.split(r"[.-]", tag) + key = [] + for part in parts: + if part.isdigit(): + key.append(int(part)) + else: + key.append(part) + return key + +found.sort(key=normalize) +print(found[-1]) +PY +) + + if [[ -z "$UPSTREAM_TAG" ]]; then + echo " WARNING: Could not fetch tags from Docker Hub for $DOCKER_IMAGE, skipping" + continue + fi + fi + + # Construct full image reference for verification + if [[ -n "$GHCR_IMAGE" ]]; then + IMAGE_REF="ghcr.io/${GHCR_IMAGE}:${UPSTREAM_TAG}" + else + IMAGE_REF="docker.io/${DOCKER_IMAGE}:${UPSTREAM_TAG}" + fi + + if [[ -n "$TAG_PREFIX" && "$UPSTREAM_TAG" == "$TAG_PREFIX"* ]]; then + UPSTREAM_VERSION="${UPSTREAM_TAG#${TAG_PREFIX}}" + else + UPSTREAM_VERSION="$UPSTREAM_TAG" + fi + + echo " Upstream tag: $UPSTREAM_TAG" + echo " Upstream version: $UPSTREAM_VERSION" + + LOCAL_VERSION=$(python3 - "$COMPOSE_FILE" "$COMPOSE_VAR" <<'PY' || echo "" +import re +import sys + +compose_file = sys.argv[1] +compose_var = sys.argv[2] + +pattern = re.compile(r"\$\{" + re.escape(compose_var) + r":-([^}]+)\}") +with open(compose_file, "r") as fh: + data = fh.read() + +match = pattern.search(data) +if not match: + sys.exit(1) + +print(match.group(1)) +PY +) + + if [[ -z "$LOCAL_VERSION" ]]; then + echo " WARNING: Could not find ${COMPOSE_VAR} default in $COMPOSE_FILE, skipping" + continue + fi + + echo " Local version: $LOCAL_VERSION" + + if [[ "$UPSTREAM_VERSION" == "$LOCAL_VERSION" ]]; then + echo " Already up to date" + continue + fi + + echo " New version available: $UPSTREAM_VERSION" + + # Verify image exists before updating + echo " Verifying image: $IMAGE_REF" + if ! docker manifest inspect "$IMAGE_REF" > /dev/null 2>&1; then + echo " WARNING: Image not found or not accessible: $IMAGE_REF, skipping" + continue + fi + + if [[ -n "${DRY_RUN:-}" ]]; then + echo " DRY RUN: would update ${COMPOSE_VAR} to ${UPSTREAM_VERSION}" + continue + fi + + if ! ./scripts/add-version.sh "$PACKAGE" "$UPSTREAM_VERSION"; then + echo " WARNING: Failed to update $COMPOSE_FILE for $PACKAGE, skipping" + continue + fi + + echo " Updated ${COMPOSE_VAR} -> ${UPSTREAM_VERSION}" + UPDATED=$((UPDATED + 1)) + +done + +echo "" +echo "==========================================" +if [[ "$UPDATED" -gt 0 ]]; then + echo "Updated $UPDATED package(s) in $COMPOSE_FILE" +else + echo "No updates applied" +fi +echo "==========================================" diff --git a/scripts/upstream-versions.json b/scripts/upstream-versions.json new file mode 100644 index 0000000..beb2db0 --- /dev/null +++ b/scripts/upstream-versions.json @@ -0,0 +1,48 @@ +{ + "cardano-node": { + "ghcr_image": "blinklabs-io/cardano-node", + "compose_var": "CARDANO_NODE_VERSION", + "tag_regex": "^[0-9]+(\\.[0-9]+){2,3}$" + }, + "cardano-node-api": { + "ghcr_image": "blinklabs-io/cardano-node-api", + "compose_var": "CARDANO_NODE_API_VERSION", + "tag_regex": "^[0-9]+(\\.[0-9]+){2,3}$" + }, + "cardano-wallet": { + "ghcr_image": "blinklabs-io/cardano-wallet", + "compose_var": "CARDANO_WALLET_VERSION", + "tag_regex": "^[0-9]+(\\.[0-9]+){2,3}$" + }, + "bluefin": { + "ghcr_image": "blinklabs-io/bluefin", + "compose_var": "BLUEFIN_VERSION", + "tag_regex": "^[0-9]+(\\.[0-9]+){2,3}$" + }, + "bursa": { + "ghcr_image": "blinklabs-io/bursa", + "compose_var": "BURSA_VERSION", + "tag_regex": "^[0-9]+(\\.[0-9]+){2,3}$" + }, + "kupo": { + "docker_image": "cardanosolutions/kupo", + "compose_var": "KUPO_VERSION", + "tag_regex": "^v?[0-9]+(\\.[0-9]+){2,3}$" + }, + "ogmios": { + "docker_image": "cardanosolutions/ogmios", + "compose_var": "OGMIOS_VERSION", + "tag_prefix": "v", + "tag_regex": "^v?[0-9]+(\\.[0-9]+){2,3}$" + }, + "tx-submit-api": { + "ghcr_image": "blinklabs-io/tx-submit-api", + "compose_var": "TX_SUBMIT_API_VERSION", + "tag_regex": "^[0-9]+(\\.[0-9]+){2,3}$" + }, + "cardano-db-sync": { + "ghcr_image": "blinklabs-io/cardano-db-sync", + "compose_var": "CARDANO_DB_SYNC_VERSION", + "tag_regex": "^[0-9]+(\\.[0-9]+){2,3}$" + } +} diff --git a/scripts/validate-versions.sh b/scripts/validate-versions.sh new file mode 100755 index 0000000..930657e --- /dev/null +++ b/scripts/validate-versions.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Validates compose version defaults for all configured packages. + +set -euo pipefail + +UPSTREAM_CONFIG="scripts/upstream-versions.json" +COMPOSE_FILE="docker-compose.yaml" + +if [[ ! -f "$UPSTREAM_CONFIG" ]]; then + echo "ERROR: Upstream config not found: $UPSTREAM_CONFIG" + exit 1 +fi + +if [[ ! -f "$COMPOSE_FILE" ]]; then + echo "ERROR: Compose file not found: $COMPOSE_FILE" + exit 1 +fi + +ERRORS=0 +CHECKED=0 + +PACKAGES=$(jq -r 'keys[]' "$UPSTREAM_CONFIG") + +for PACKAGE in $PACKAGES; do + COMPOSE_VAR=$(jq -r --arg pkg "$PACKAGE" '.[$pkg].compose_var // ""' "$UPSTREAM_CONFIG") + CHECKED=$((CHECKED + 1)) + + if [[ -z "$COMPOSE_VAR" || "$COMPOSE_VAR" == "null" ]]; then + echo "ERROR: compose_var not set for $PACKAGE" + ERRORS=$((ERRORS + 1)) + continue + fi + + PATTERN='${'"${COMPOSE_VAR}"':-' + if ! grep -qF "$PATTERN" "$COMPOSE_FILE"; then + echo "ERROR: ${COMPOSE_VAR} not found in $COMPOSE_FILE" + ERRORS=$((ERRORS + 1)) + continue + fi + + DEFAULT_VERSION=$(python3 - "$COMPOSE_FILE" "$COMPOSE_VAR" <<'PY' || echo "" +import re +import sys + +compose_file = sys.argv[1] +compose_var = sys.argv[2] + +pattern = re.compile(r"\$\{" + re.escape(compose_var) + r":-([^}]+)\}") +with open(compose_file, "r") as fh: + data = fh.read() + +match = pattern.search(data) +if not match: + sys.exit(1) + +print(match.group(1)) +PY +) + + if [[ -z "$DEFAULT_VERSION" ]]; then + echo "ERROR: ${COMPOSE_VAR} has no default version" + ERRORS=$((ERRORS + 1)) + continue + fi + +done + +echo "" +echo "Checked $CHECKED package(s)" + +if [[ $ERRORS -gt 0 ]]; then + echo "Found $ERRORS error(s)" + exit 1 +fi + +echo "All versions valid"