diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66d3692f5..edff2d9e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,31 +8,48 @@ on: permissions: contents: read + packages: read jobs: - wait-for-image: - name: Wait for Docker image to be pushed to Docker Hub - uses: ./.github/workflows/wait-for-docker-image.yml - with: - image: >- - ${{ - github.event_name == 'pull_request' - && format('ryanmillard/img2num-dev:pr-{0}', github.event.pull_request.number) - || 'ryanmillard/img2num-dev:main' - }} + set-image: + name: Set Docker image + runs-on: ubuntu-latest + outputs: + image: ${{ steps.set.outputs.image }} + steps: + - name: Set image + id: set + run: | + MAIN_IMAGE="ghcr.io/ryan-millard/img2num-dev:main" + PR_TAG="pr-${{ github.event.pull_request.number }}" + REPO="ryan-millard/img2num-dev" + DH_IMAGE="${REPO}:${PR_TAG}" + GHCR_IMAGE="ghcr.io/${DH_IMAGE}" + + GHCR_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json" \ + "https://ghcr.io/v2/ryan-millard/img2num-dev/manifests/${PR_TAG}") + + DH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + "https://registry.hub.docker.com/v2/repositories/${REPO}/tags/${PR_TAG}/") + + if [[ "$GHCR_STATUS" == "200" ]]; then + echo "image=${GHCR_IMAGE}" + echo "image=${GHCR_IMAGE}" >> $GITHUB_OUTPUT + elif [[ "$DH_STATUS" == "200" ]]; then + echo "image=${DH_IMAGE}" + echo "image=${DH_IMAGE}" >> $GITHUB_OUTPUT + else + echo "image=${MAIN_IMAGE}" + echo "image=${MAIN_IMAGE}" >> $GITHUB_OUTPUT + fi lint: name: Lint & Validate Code - needs: wait-for-image + needs: [set-image] runs-on: ubuntu-latest container: - image: >- - ${{ - github.event_name == 'pull_request' - && format('ryanmillard/img2num-dev:pr-{0}', github.event.pull_request.number) - || 'ryanmillard/img2num-dev:main' - }} - + image: ${{ needs.set-image.outputs.image }} steps: - name: Checkout code # No need for submodules since we are not running code @@ -56,39 +73,24 @@ jobs: cmake-build: name: Build C/C++ uses: ./.github/workflows/cmake-build.yml - needs: wait-for-image + needs: [set-image] with: - image: >- - ${{ - github.event_name == 'pull_request' - && format('ryanmillard/img2num-dev:pr-{0}', github.event.pull_request.number) - || 'ryanmillard/img2num-dev:main' - }} + image: ${{ needs.set-image.outputs.image }} build-react-app: name: Build React App uses: ./.github/workflows/build-react-app.yml - needs: [cmake-build, wait-for-image] + needs: [set-image, cmake-build] with: - image: >- - ${{ - github.event_name == 'pull_request' - && format('ryanmillard/img2num-dev:pr-{0}', github.event.pull_request.number) - || 'ryanmillard/img2num-dev:main' - }} + image: ${{ needs.set-image.outputs.image }} wasm-artifacts-path: packages/js/build-wasm/ build-docs: name: Build Documentation Site uses: ./.github/workflows/build-docs.yml - needs: wait-for-image + needs: [set-image] with: - image: >- - ${{ - github.event_name == 'pull_request' - && format('ryanmillard/img2num-dev:pr-{0}', github.event.pull_request.number) - || 'ryanmillard/img2num-dev:main' - }} + image: ${{ needs.set-image.outputs.image }} secrets: ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 45aef9b85..34a1fd957 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,6 +20,8 @@ jobs: wait-for-image: name: Wait for Docker image to be pushed to Docker Hub uses: ./.github/workflows/wait-for-docker-image.yml + with: + dh_image: ryanmillard/img2num-dev:main cmake-build: name: Build WASM diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 1c1f9b2fd..a7ea29c1e 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -1,40 +1,163 @@ name: Docker CI on: - pull_request: - types: [opened, synchronize, reopened, closed] - + issue_comment: + types: [created] push: branches: [main] tags: ["v*"] + pull_request: + types: [closed] permissions: contents: read + pull-requests: read env: - IMAGE_NAME: ryanmillard/img2num-dev + GHCR_IMAGE: ghcr.io/ryan-millard/img2num-dev + DH_IMAGE: ryanmillard/img2num-dev jobs: - build: - if: github.event.action != 'closed' + guard: + # For push: always run (build on merge/tag) + # For issue_comment: only on PR comments starting with /docker-build or /docker-cleanup + # For pull_request: skip — cleanup is handled by the cleanup job directly + if: | + github.event_name == 'push' || + ( + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + ( + startsWith(github.event.comment.body, '/docker-build') || + startsWith(github.event.comment.body, '/docker-cleanup') + ) + ) runs-on: ubuntu-latest + permissions: + pull-requests: write + outputs: + pr_number: ${{ steps.resolve.outputs.pr_number }} + sha: ${{ steps.resolve.outputs.sha }} + authorized: ${{ steps.authz.outputs.authorized }} + command: ${{ steps.resolve.outputs.command }} + steps: + - name: Resolve PR number, SHA, and command + id: resolve + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_BODY: ${{ github.event.comment.body }} + REPO: ${{ github.repository }} + PUSH_SHA: ${{ github.sha }} + run: | + if [[ "$EVENT_NAME" == "issue_comment" ]]; then + if [[ "$COMMENT_BODY" == /docker-cleanup* ]]; then + COMMAND="cleanup" + SHA=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha') + else + COMMAND="build" + PROVIDED_SHA=$(echo "$COMMENT_BODY" | awk '{print $2}') + if [[ -z "$PROVIDED_SHA" ]]; then + echo "::error::Usage: /docker-build " + exit 1 + fi + CURRENT_SHA=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha') + if [[ "$CURRENT_SHA" != "$PROVIDED_SHA"* ]]; then + echo "::error::Provided SHA $PROVIDED_SHA does not match current PR head ($CURRENT_SHA). Check the PR and try again." + exit 1 + fi + SHA="$CURRENT_SHA" + fi + echo "pr_number=${PR_NUMBER}" >> $GITHUB_OUTPUT + echo "sha=${SHA}" >> $GITHUB_OUTPUT + echo "command=${COMMAND}" >> $GITHUB_OUTPUT + + # Non-comments - pushes to main + else + echo "pr_number=" >> $GITHUB_OUTPUT + echo "sha=${PUSH_SHA}" >> $GITHUB_OUTPUT + echo "command=build" >> $GITHUB_OUTPUT + fi + + - name: Check commenter has write access + id: authz + if: github.event_name == 'issue_comment' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PERMISSION=$(gh api \ + repos/${{ github.repository }}/collaborators/${{ github.event.comment.user.login }}/permission \ + --jq '.permission') + echo "Permission level: $PERMISSION" + if [[ "$PERMISSION" == "write" || "$PERMISSION" == "admin" || "$PERMISSION" == "maintain" ]]; then + + # Extra safety - limit the build permissions to only experienced maintainers + if [[ "$COMMENTER" == "Ryan-Millard" || "$COMMENTER" == "Krasner" ]]; then + echo "authorized=true" >> $GITHUB_OUTPUT + else + echo "authorized=false" >> $GITHUB_OUTPUT + fi + + else + echo "authorized=false" >> $GITHUB_OUTPUT + fi + + - name: React to comment (authorized) + if: github.event_name == 'issue_comment' && steps.authz.outputs.authorized == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api \ + repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + -X POST -f content='+1' + + - name: React to comment (unauthorized) + if: github.event_name == 'issue_comment' && steps.authz.outputs.authorized == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api \ + repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ + -X POST -f content='-1' + echo "::error::User ${{ github.event.comment.user.login }} does not have write access." + exit 1 + build: + needs: guard + if: | + needs.guard.result == 'success' && + needs.guard.outputs.command == 'build' && + (github.event_name == 'push' || needs.guard.outputs.authorized == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write outputs: clean_core_tag: ${{ steps.meta.outputs.clean_core_tag }} - tags: ${{ steps.meta.outputs.tags }} - cache_ref: ${{ steps.meta.outputs.cache_ref }} + tags: ${{ steps.meta.outputs.tags }} + cache_ref: ${{ steps.meta.outputs.cache_ref }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + ref: ${{ needs.guard.outputs.sha }} submodules: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + - name: Login to GitHub Container Registry + uses: docker/login-action@4a8376e001c7725687f0624d1c3698a3f6ab337e + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to Docker Hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + if: github.event_name == 'push' + uses: docker/login-action@4a8376e001c7725687f0624d1c3698a3f6ab337e with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -42,115 +165,120 @@ jobs: - name: Compute tags id: meta run: | - IMAGE="${{ env.IMAGE_NAME }}" + GHCR="${{ env.GHCR_IMAGE }}" + DH="${{ env.DH_IMAGE }}" EVENT="${{ github.event_name }}" - if [[ "$EVENT" == "pull_request" ]]; then - PR="${{ github.event.pull_request.number }}" - + if [[ "$EVENT" == "issue_comment" ]]; then + PR="${{ needs.guard.outputs.pr_number }}" CLEAN_CORE_TAG="pr-${PR}" - TAGS="${IMAGE}:pr-${PR}" - CACHE_REF="${IMAGE}:cache-pr-${PR}" + TAGS="${GHCR}:pr-${PR}" + CACHE_REF="${GHCR}:cache-pr-${PR}" elif [[ "$EVENT" == "push" && "${{ github.ref_type }}" == "tag" ]]; then VERSION="${GITHUB_REF_NAME}" - CLEAN_CORE_TAG="$VERSION" - TAGS="${IMAGE}:${VERSION},${IMAGE}:latest" - CACHE_REF="${IMAGE}:buildcache" + TAGS="${GHCR}:${VERSION},${GHCR}:latest,${DH}:${VERSION},${DH}:latest" + CACHE_REF="${GHCR}:buildcache" else CLEAN_CORE_TAG="main" - TAGS="${IMAGE}:main,${IMAGE}:latest" - CACHE_REF="${IMAGE}:buildcache" + TAGS="${GHCR}:main,${GHCR}:latest,${DH}:main,${DH}:latest" + CACHE_REF="${GHCR}:buildcache" fi echo "clean_core_tag=$CLEAN_CORE_TAG" >> $GITHUB_OUTPUT - echo "tags=$TAGS" >> $GITHUB_OUTPUT - echo "cache_ref=$CACHE_REF" >> $GITHUB_OUTPUT + echo "tags=$TAGS" >> $GITHUB_OUTPUT + echo "cache_ref=$CACHE_REF" >> $GITHUB_OUTPUT - name: Build & Push Docker image - if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name != 'pull_request' uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f with: context: . file: Dockerfile.dev - push: true + push: false tags: ${{ steps.meta.outputs.tags }} cache-from: | type=registry,ref=${{ steps.meta.outputs.cache_ref }} - type=registry,ref=${{ env.IMAGE_NAME }}:buildcache + type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache cache-to: type=registry,ref=${{ steps.meta.outputs.cache_ref }},mode=max - notify: - needs: build - if: github.event_name == 'pull_request' && needs.build.result == 'success' - + needs: [guard, build] + if: github.event_name == 'issue_comment' && needs.build.result == 'success' runs-on: ubuntu-latest permissions: pull-requests: write env: - IMAGE_NAME: ryanmillard/img2num-dev CLEAN_CORE_TAG: ${{ needs.build.outputs.clean_core_tag }} - CORE_TAG: ${{ needs.build.outputs.clean_core_tag }} steps: - # Avoid chat spamming by checking for an existing comment - - name: Find Docker comment + - name: Find existing Docker comment id: fc uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad with: - issue-number: ${{ github.event.pull_request.number }} + issue-number: ${{ needs.guard.outputs.pr_number }} body-includes: "" - - name: Debug comment detection - if: steps.fc.outputs.comment-id != '' + - name: Compute lowercase image ref + id: img run: | - echo "Found comment ID: ${{ steps.fc.outputs.comment-id }}" - - - name: Create Docker comment - if: steps.fc.outputs.comment-id == '' + LOWER_OWNER_NAME="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" + GHCR_IMAGE="ghcr.io/${LOWER_OWNER_NAME}/img2num-dev" + echo "lower_owner_name=${LOWER_OWNER_NAME}" >> $GITHUB_OUTPUT + echo "ghcr_image=${GHCR_IMAGE}" >> $GITHUB_OUTPUT + + - name: Create or update Docker comment uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 with: - issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ needs.guard.outputs.pr_number }} + edit-mode: replace body: | ## 🐳 Docker image built successfully! - + ### Image - [`${{ env.IMAGE_NAME }}:${{ env.CORE_TAG }}`](https://hub.docker.com/r/${{ env.IMAGE_NAME }}/tags?name=${{ env.CORE_TAG }}) - + [`${{ steps.img.outputs.ghcr_image }}:${{ env.CLEAN_CORE_TAG }}`](https://github.com/${{ steps.img.outputs.lower_owner_name }}/${{ github.event.repository.name }}/pkgs/container/img2num-dev/${{ env.CLEAN_CORE_TAG }}) + ### Run it locally: ```bash - IMG2NUM_IMAGE=${{ env.IMAGE_NAME }}:${{ env.CORE_TAG }} ./img2num sh - + IMG2NUM_IMAGE=${{ steps.img.outputs.ghcr_image }}:${{ env.CLEAN_CORE_TAG }} ./img2num sh + ``` - cleanup: - if: github.event.action == 'closed' + cleanup-on-close: + if: github.event_name == 'pull_request' runs-on: ubuntu-latest + permissions: + packages: write + steps: + - name: Delete PR images from GHCR + uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 + with: + package-name: img2num-dev + package-type: container + delete-untagged-versions: false + dry-run: false + min-versions-to-keep: 0 + ignore-versions: "^(?!pr-${{ github.event.pull_request.number }}$|cache-pr-${{ github.event.pull_request.number }}$).*" + cleanup-on-command: + needs: guard + if: | + needs.guard.result == 'success' && + needs.guard.outputs.command == 'cleanup' && + needs.guard.outputs.authorized == 'true' + runs-on: ubuntu-latest + permissions: + packages: write steps: - - name: Delete PR images - run: | - PR_NUMBER=${{ github.event.pull_request.number }} - - TOKEN=$(curl -s -X POST \ - -H "Content-Type: application/json" \ - -d '{"username":"${{ secrets.DOCKERHUB_USERNAME }}","password":"${{ secrets.DOCKERHUB_TOKEN }}"}' \ - https://hub.docker.com/v2/users/login/ | jq -r .token) - - IMAGE="${{ env.IMAGE_NAME }}" - - TAGS=( - "pr-${PR_NUMBER}" - "cache-pr-${PR_NUMBER}" - ) - - for TAG in "${TAGS[@]}"; do - curl -s -X DELETE \ - -H "Authorization: JWT ${TOKEN}" \ - "https://hub.docker.com/v2/repositories/${IMAGE}/tags/${TAG}/" \ - || echo "Tag $TAG not found or already deleted" - done + - name: Delete PR images from GHCR + uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 + with: + package-name: img2num-dev + package-type: container + delete-untagged-versions: false + dry-run: false + min-versions-to-keep: 0 + ignore-versions: "^(?!pr-${{ needs.guard.outputs.pr_number }}$|cache-pr-${{ needs.guard.outputs.pr_number }}$).*" diff --git a/.github/workflows/wait-for-docker-image.yml b/.github/workflows/wait-for-docker-image.yml index 7241e3368..e4da5b7be 100644 --- a/.github/workflows/wait-for-docker-image.yml +++ b/.github/workflows/wait-for-docker-image.yml @@ -10,27 +10,47 @@ name: Wait for Docker Image on: workflow_call: inputs: - image: + dh_image: required: false type: string default: ryanmillard/img2num-dev:main description: > - Docker image that this workflow waits for. It will check Docker Hub for this image. Defaults to: ryanmillard/img2num-dev:main + Docker Hub image to wait for. Defaults to: ryanmillard/img2num-dev:main + ghcr_image: + required: false + type: string + default: ghcr.io/ryan-millard/img2num-dev:main + description: > + GHCR image to wait for. Defaults to: ghcr.io/ryan-millard/img2num-dev:main max_attempts: required: false type: number - default: 8 + default: 13 description: > Maximum number of pull attempts before giving up. - With the default of 8 attempts and an initial delay of 15s, - the total worst-case wait is ~63 minutes (15+30+60+120+240+480+960+1920s). + With the default of 13 attempts, 15s initial delay, 2m initial wait, and 1.5x backoff multiplier, + the total worst-case wait is ~96 minutes (120s flat + 15+22+33+49+74+111+166+249+374+561+842+1263+1894s). initial_delay_seconds: required: false type: number default: 15 description: > - Initial delay in seconds between attempts. Doubles after each failure. - With the default of 15s and 8 attempts, worst-case wait is ~63 minutes. + Initial delay in seconds between attempts. Multiplied by the backoff multiplier after each failure. + With the default of 15s, 13 attempts, 2m initial wait, and 1.5x multiplier, worst-case wait is ~96 minutes. + initial_wait_seconds: + required: false + type: number + default: 120 + description: > + Flat wait in seconds before the first attempt. Useful to avoid hammering the registry + before the image has had any chance to appear. Defaults to 120s (2 minutes). + backoff_multiplier: + required: false + type: number + default: 1.5 + description: > + Multiplier applied to the delay after each failed attempt. Defaults to 1.5. + Use 2 for classic exponential backoff. permissions: {} @@ -39,23 +59,74 @@ jobs: name: Wait for Docker Image runs-on: ubuntu-latest steps: - - name: Wait for image to be available + - name: Set up variables + id: setup run: | - # Retries with exponential backoff. - # Defaults: 8 attempts, 15s initial delay -> delays of 15, 30, 60, 120, 240, 480, 960, 1920 (all in seconds) - # Total worst-case wait with defaults: ~63 minutes (enough for Dawn to typically be built into the image) - IMAGE="${{ inputs.image }}" - MAX_ATTEMPTS=${{ inputs.max_attempts }} - DELAY=${{ inputs.initial_delay_seconds }} - IMAGE_NAME="${IMAGE%%:*}" - TAG="${IMAGE##*:}" - WORKFLOW_STARTED_AT="${{ github.event.repository.updated_at }}" - WORKFLOW_START_TS=$(date -d "$WORKFLOW_STARTED_AT" +%s) + DH_IMAGE="${{ inputs.dh_image }}" + GHCR_IMAGE="${{ inputs.ghcr_image }}" + WORKFLOW_STARTED_AT="${{ github.event.workflow_run.created_at || github.event.repository.updated_at }}" + echo "dh_image=$DH_IMAGE" >> "$GITHUB_OUTPUT" + echo "dh_image_name=${DH_IMAGE%%:*}" >> "$GITHUB_OUTPUT" + echo "dh_tag=${DH_IMAGE##*:}" >> "$GITHUB_OUTPUT" + echo "ghcr_image=$GHCR_IMAGE" >> "$GITHUB_OUTPUT" + echo "ghcr_image_name=${GHCR_IMAGE%%:*}" >> "$GITHUB_OUTPUT" + echo "ghcr_tag=${GHCR_IMAGE##*:}" >> "$GITHUB_OUTPUT" + echo "max_attempts=${{ inputs.max_attempts }}" >> "$GITHUB_OUTPUT" + echo "initial_delay=${{ inputs.initial_delay_seconds }}" >> "$GITHUB_OUTPUT" + echo "initial_wait=${{ inputs.initial_wait_seconds }}" >> "$GITHUB_OUTPUT" + echo "multiplier=${{ inputs.backoff_multiplier }}" >> "$GITHUB_OUTPUT" + echo "workflow_started_at=$WORKFLOW_STARTED_AT" >> "$GITHUB_OUTPUT" + echo "workflow_start_ts=$(date -d "$WORKFLOW_STARTED_AT" +%s)" >> "$GITHUB_OUTPUT" + + - name: Initial wait + run: | + INITIAL_WAIT="${{ steps.setup.outputs.initial_wait }}" + if [[ "$INITIAL_WAIT" -gt 0 ]]; then + echo "Waiting ${INITIAL_WAIT}s before first attempt..." + sleep "$INITIAL_WAIT" + fi + + - name: Wait for image with exponential backoff + run: | + DH_IMAGE_NAME="${{ steps.setup.outputs.dh_image_name }}" + DH_TAG="${{ steps.setup.outputs.dh_tag }}" + GHCR_IMAGE_NAME="${{ steps.setup.outputs.ghcr_image_name }}" + GHCR_TAG="${{ steps.setup.outputs.ghcr_tag }}" + MAX_ATTEMPTS="${{ steps.setup.outputs.max_attempts }}" + DELAY="${{ steps.setup.outputs.initial_delay }}" + MULTIPLIER="${{ steps.setup.outputs.multiplier }}" + WORKFLOW_STARTED_AT="${{ steps.setup.outputs.workflow_started_at }}" + WORKFLOW_START_TS="${{ steps.setup.outputs.workflow_start_ts }}" for i in $(seq 1 $MAX_ATTEMPTS); do - echo "Attempt $i of $MAX_ATTEMPTS: checking $IMAGE..." + echo "Attempt $i of $MAX_ATTEMPTS..." + + # Check GHCR + REPO="${GHCR_IMAGE_NAME#ghcr.io/}" + TOKEN=$(curl -sf -u "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" \ + "https://ghcr.io/token?scope=repository:${REPO}:pull" | jq -r '.token // empty') + if [[ -n "$TOKEN" ]]; then + RESPONSE=$(curl -s -D - -o /dev/null \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json" \ + "https://ghcr.io/v2/${REPO}/manifests/${GHCR_TAG}") + + HTTP_CODE=$(echo "$RESPONSE" | grep -m1 "^HTTP" | awk '{print $2}') + LAST_MODIFIED=$(echo "$RESPONSE" | grep -i "^last-modified:" | sed 's/last-modified: //i' | tr -d '\r') + + if [[ "$HTTP_CODE" == "200" && -n "$LAST_MODIFIED" ]]; then + LAST_MODIFIED_TS=$(date -d "$LAST_MODIFIED" +%s) + if [[ "$LAST_MODIFIED_TS" -ge "$WORKFLOW_START_TS" ]]; then + echo "GHCR image is fresh, proceeding." + exit 0 + else + echo "GHCR image exists but is stale." + fi + fi + fi - LAST_PUSHED=$(curl -sf "https://hub.docker.com/v2/repositories/${IMAGE_NAME}/tags/${TAG}/" \ + # Check Docker Hub + LAST_PUSHED=$(curl -sf "https://hub.docker.com/v2/repositories/${DH_IMAGE_NAME}/tags/${DH_TAG}/" \ | jq -r '.tag_last_pushed // empty') if [[ -n "$LAST_PUSHED" ]]; then @@ -74,7 +145,7 @@ jobs: echo "Waiting ${DELAY}s..." sleep $DELAY - DELAY=$((DELAY * 2)) + DELAY=$(echo "$DELAY $MULTIPLIER" | awk '{printf "%d", $1 * $2}') done echo "Image never became fresh after $MAX_ATTEMPTS attempts"