From 4bf3940315bcc2331bc2d7124636834a60715100 Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Sat, 18 Apr 2026 20:19:25 +0200 Subject: [PATCH 01/14] refactor(docker-ci): switch to use GitHub packages alongside Docker Hub (better PR integration) --- .github/workflows/ci.yml | 62 +++++++--------- .github/workflows/docker-ci.yml | 78 ++++++++++----------- .github/workflows/wait-for-docker-image.yml | 2 +- 3 files changed, 66 insertions(+), 76 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66d3692f5..e3fa4e70b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,31 +8,36 @@ on: permissions: contents: read + packages: read jobs: + set-image: + name: Set Docker image + runs-on: ubuntu-latest + outputs: + image: ${{ steps.set.outputs.image }} + steps: + - name: Set image + id: set + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "image=ghcr.io/ryanmillard/img2num-dev:pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + else + echo "image=ghcr.io/ryanmillard/img2num-dev:main" >> $GITHUB_OUTPUT + fi wait-for-image: - name: Wait for Docker image to be pushed to Docker Hub + needs: set-image + name: Wait for Docker image to be pushed to GHCR 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' - }} + image: ${{ needs.set-image.outputs.image }} lint: name: Lint & Validate Code - needs: wait-for-image + needs: [set-image, wait-for-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 +61,24 @@ jobs: cmake-build: name: Build C/C++ uses: ./.github/workflows/cmake-build.yml - needs: wait-for-image + needs: [set-image, wait-for-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, wait-for-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 }} 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, wait-for-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/docker-ci.yml b/.github/workflows/docker-ci.yml index 1c1f9b2fd..f68416768 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -10,9 +10,12 @@ on: permissions: contents: read + packages: write # needed for GHCR + pull-requests: write # needed for PR comments env: - IMAGE_NAME: ryanmillard/img2num-dev + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/img2num-dev + DH_IMAGE: ryanmillard/img2num-dev jobs: build: @@ -33,7 +36,15 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to Docker Hub + if: github.event_name != 'pull_request' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -42,27 +53,28 @@ jobs: - name: Compute tags id: meta run: | - IMAGE="${{ env.IMAGE_NAME }}" + GHCR="ghcr.io/$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')/img2num-dev" + DH="${{ env.DH_IMAGE }}" EVENT="${{ github.event_name }}" if [[ "$EVENT" == "pull_request" ]]; then PR="${{ github.event.pull_request.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 @@ -70,7 +82,6 @@ jobs: 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: . @@ -79,7 +90,7 @@ jobs: 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 @@ -92,9 +103,8 @@ jobs: pull-requests: write env: - IMAGE_NAME: ryanmillard/img2num-dev + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/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 @@ -109,7 +119,7 @@ jobs: if: steps.fc.outputs.comment-id != '' run: | echo "Found comment ID: ${{ steps.fc.outputs.comment-id }}" - + - name: Create Docker comment if: steps.fc.outputs.comment-id == '' uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 @@ -118,39 +128,29 @@ jobs: 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 }}) - + [`${{ env.GHCR_IMAGE }}:${{ env.CLEAN_CORE_TAG }}`](https://github.com/${{ github.repository_owner }}/packages/container/img2num-dev/${{ env.CLEAN_CORE_TAG }}) + ### Run it locally: ```bash - IMG2NUM_IMAGE=${{ env.IMAGE_NAME }}:${{ env.CORE_TAG }} ./img2num sh + IMG2NUM_IMAGE=${{ env.GHCR_IMAGE }}:${{ env.CLEAN_CORE_TAG }} ./img2num sh + ``` cleanup: if: github.event.action == 'closed' 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 + # Deletes both pr-N and cache-pr-N tags + min-versions-to-keep: 0 + ignore-versions: "^(?!pr-${{ github.event.pull_request.number }}|cache-pr-${{ github.event.pull_request.number }}).*" diff --git a/.github/workflows/wait-for-docker-image.yml b/.github/workflows/wait-for-docker-image.yml index 7241e3368..c80649c9a 100644 --- a/.github/workflows/wait-for-docker-image.yml +++ b/.github/workflows/wait-for-docker-image.yml @@ -15,7 +15,7 @@ on: 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 image that this workflow waits for. It will check Docker Hub and GHCR for this image. Defaults to: ryanmillard/img2num-dev:main max_attempts: required: false type: number From 7f2582ede2679b43fad3e37f8b846aeea0f27703 Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Sat, 18 Apr 2026 21:24:58 +0200 Subject: [PATCH 02/14] ci(docker): fix notify job comment --- .github/workflows/docker-ci.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index f68416768..d145d4232 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -103,7 +103,6 @@ jobs: pull-requests: write env: - GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/img2num-dev CLEAN_CORE_TAG: ${{ needs.build.outputs.clean_core_tag }} steps: @@ -120,6 +119,15 @@ jobs: run: | echo "Found comment ID: ${{ steps.fc.outputs.comment-id }}" + - name: Compute lowercase image ref + id: img + run: | + # GHCR for some reason requires lowercase when pulling + 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 Docker comment if: steps.fc.outputs.comment-id == '' uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 @@ -130,11 +138,11 @@ jobs: ## 🐳 Docker image built successfully! ### Image - [`${{ env.GHCR_IMAGE }}:${{ env.CLEAN_CORE_TAG }}`](https://github.com/${{ github.repository_owner }}/packages/container/img2num-dev/${{ env.CLEAN_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.GHCR_IMAGE }}:${{ env.CLEAN_CORE_TAG }} ./img2num sh + IMG2NUM_IMAGE=${{ steps.img.outputs.ghcr_image }}:${{ env.CLEAN_CORE_TAG }} ./img2num sh ``` From b5b7cbcca5b4cdc050026cbf50b745ac7b7df6c3 Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Sat, 18 Apr 2026 22:05:00 +0200 Subject: [PATCH 03/14] ci(wait-for-docker-image.yml): fix check logic by including GHCR --- .github/workflows/wait-for-docker-image.yml | 27 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wait-for-docker-image.yml b/.github/workflows/wait-for-docker-image.yml index c80649c9a..7b5532c05 100644 --- a/.github/workflows/wait-for-docker-image.yml +++ b/.github/workflows/wait-for-docker-image.yml @@ -49,14 +49,35 @@ jobs: DELAY=${{ inputs.initial_delay_seconds }} IMAGE_NAME="${IMAGE%%:*}" TAG="${IMAGE##*:}" - WORKFLOW_STARTED_AT="${{ github.event.repository.updated_at }}" + WORKFLOW_STARTED_AT="${{ github.event.workflow_run.created_at || github.event.repository.updated_at }}" WORKFLOW_START_TS=$(date -d "$WORKFLOW_STARTED_AT" +%s) for i in $(seq 1 $MAX_ATTEMPTS); do echo "Attempt $i of $MAX_ATTEMPTS: checking $IMAGE..." - LAST_PUSHED=$(curl -sf "https://hub.docker.com/v2/repositories/${IMAGE_NAME}/tags/${TAG}/" \ - | jq -r '.tag_last_pushed // empty') + LAST_PUSHED="" + if [[ "$IMAGE_NAME" == ghcr.io/* ]]; then + # GHCR: resolve via the registry API. Requires a bearer token; GITHUB_TOKEN works for public/owned images. + REPO="${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 + # HEAD the manifest; Docker-Content-Digest presence indicates tag exists. + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -I \ + -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/${TAG}") + if [[ "$HTTP_CODE" == "200" ]]; then + # GHCR doesn't return push timestamp via registry API; treat presence as fresh-enough, + # OR query the GitHub Packages API for updated_at if you want the staleness check preserved. + echo "Image present in GHCR." + exit 0 + fi + fi + else + LAST_PUSHED=$(curl -sf "https://hub.docker.com/v2/repositories/${IMAGE_NAME}/tags/${TAG}/" \ + | jq -r '.tag_last_pushed // empty') + fi if [[ -n "$LAST_PUSHED" ]]; then LAST_PUSHED_TS=$(date -d "$LAST_PUSHED" +%s) From 2dd5cba82bbcf6a5f0b2ff07348cd3933a984761 Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Sat, 18 Apr 2026 22:14:51 +0200 Subject: [PATCH 04/14] ci(wait-for-docker-image): add default initial wait time & split into steps --- .github/workflows/wait-for-docker-image.yml | 69 +++++++++++++++------ 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/.github/workflows/wait-for-docker-image.yml b/.github/workflows/wait-for-docker-image.yml index 7b5532c05..14faf0d57 100644 --- a/.github/workflows/wait-for-docker-image.yml +++ b/.github/workflows/wait-for-docker-image.yml @@ -19,18 +19,32 @@ on: 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,37 +53,54 @@ 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.workflow_run.created_at || github.event.repository.updated_at }}" - WORKFLOW_START_TS=$(date -d "$WORKFLOW_STARTED_AT" +%s) + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + echo "image_name=${IMAGE%%:*}" >> "$GITHUB_OUTPUT" + echo "tag=${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: | + IMAGE="${{ steps.setup.outputs.image }}" + IMAGE_NAME="${{ steps.setup.outputs.image_name }}" + TAG="${{ steps.setup.outputs.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..." LAST_PUSHED="" if [[ "$IMAGE_NAME" == ghcr.io/* ]]; then - # GHCR: resolve via the registry API. Requires a bearer token; GITHUB_TOKEN works for public/owned images. REPO="${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 - # HEAD the manifest; Docker-Content-Digest presence indicates tag exists. HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -I \ -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/${TAG}") if [[ "$HTTP_CODE" == "200" ]]; then - # GHCR doesn't return push timestamp via registry API; treat presence as fresh-enough, - # OR query the GitHub Packages API for updated_at if you want the staleness check preserved. echo "Image present in GHCR." exit 0 fi @@ -95,7 +126,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" From d221950ff55f707cf03aa8137606a27aec0bade8 Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Sat, 18 Apr 2026 22:29:26 +0200 Subject: [PATCH 05/14] fix(wait-for-docker-image): add ghcr & dh checks so it checks both registries --- .github/workflows/ci.yml | 6 +- .github/workflows/deploy.yml | 2 + .github/workflows/wait-for-docker-image.yml | 76 ++++++++++++--------- 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3fa4e70b..dc5db25b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,16 +21,16 @@ jobs: id: set run: | if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "image=ghcr.io/ryanmillard/img2num-dev:pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + echo "image=ghcr.io/ryan-millard/img2num-dev:pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT else - echo "image=ghcr.io/ryanmillard/img2num-dev:main" >> $GITHUB_OUTPUT + echo "image=ghcr.io/ryan-millard/img2num-dev:main" >> $GITHUB_OUTPUT fi wait-for-image: needs: set-image name: Wait for Docker image to be pushed to GHCR uses: ./.github/workflows/wait-for-docker-image.yml with: - image: ${{ needs.set-image.outputs.image }} + ghcr_image: ${{ needs.set-image.outputs.image }} lint: name: Lint & Validate Code 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/wait-for-docker-image.yml b/.github/workflows/wait-for-docker-image.yml index 14faf0d57..f1c257696 100644 --- a/.github/workflows/wait-for-docker-image.yml +++ b/.github/workflows/wait-for-docker-image.yml @@ -10,12 +10,18 @@ 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 and GHCR 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 @@ -56,17 +62,21 @@ jobs: - name: Set up variables id: setup run: | - IMAGE="${{ inputs.image }}" + 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 "image=$IMAGE" >> "$GITHUB_OUTPUT" - echo "image_name=${IMAGE%%:*}" >> "$GITHUB_OUTPUT" - echo "tag=${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" + 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: | @@ -78,9 +88,10 @@ jobs: - name: Wait for image with exponential backoff run: | - IMAGE="${{ steps.setup.outputs.image }}" - IMAGE_NAME="${{ steps.setup.outputs.image_name }}" - TAG="${{ steps.setup.outputs.tag }}" + 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 }}" @@ -88,28 +99,27 @@ jobs: 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..." - LAST_PUSHED="" - if [[ "$IMAGE_NAME" == ghcr.io/* ]]; then - REPO="${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 - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -I \ - -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/${TAG}") - if [[ "$HTTP_CODE" == "200" ]]; then - echo "Image present in GHCR." - exit 0 - fi + # 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 + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -I \ + -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}") + if [[ "$HTTP_CODE" == "200" ]]; then + echo "Image present in GHCR." + exit 0 fi - else - LAST_PUSHED=$(curl -sf "https://hub.docker.com/v2/repositories/${IMAGE_NAME}/tags/${TAG}/" \ - | jq -r '.tag_last_pushed // empty') fi + # 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 LAST_PUSHED_TS=$(date -d "$LAST_PUSHED" +%s) echo "Image last pushed at: $LAST_PUSHED (workflow started at: $WORKFLOW_STARTED_AT)" From 182549e4d45fca62dc77740aec7c08197faa549d Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Sat, 18 Apr 2026 22:37:09 +0200 Subject: [PATCH 06/14] ci(docker-ci): fix prefix-collision bug in regex --- .github/workflows/docker-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index d145d4232..3ec8772f4 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -159,6 +159,9 @@ jobs: package-name: img2num-dev package-type: container delete-untagged-versions: false + dry-run: true # Deletes both pr-N and cache-pr-N tags + # $ anchors each alternative so pr-5 does not match pr-50, pr-51, etc. + # (See CodeRabbit's related review comment: https://github.com/Ryan-Millard/Img2Num/pull/312#discussion_r3105632912) min-versions-to-keep: 0 - ignore-versions: "^(?!pr-${{ github.event.pull_request.number }}|cache-pr-${{ github.event.pull_request.number }}).*" + ignore-versions: "^(?!pr-${{ github.event.pull_request.number }}$|cache-pr-${{ github.event.pull_request.number }}$).*" From b0b1995793f0b885e4ec8fcddbff36bac6f163bb Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Sat, 18 Apr 2026 22:44:17 +0200 Subject: [PATCH 07/14] ci(wait-for-docker-image): check GHCR push time to verify newness of build --- .github/workflows/wait-for-docker-image.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wait-for-docker-image.yml b/.github/workflows/wait-for-docker-image.yml index f1c257696..e4da5b7be 100644 --- a/.github/workflows/wait-for-docker-image.yml +++ b/.github/workflows/wait-for-docker-image.yml @@ -106,13 +106,22 @@ jobs: 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 - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -I \ + 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}") - if [[ "$HTTP_CODE" == "200" ]]; then - echo "Image present in GHCR." - exit 0 + + 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 From b5ac75c21d98b72dd63e7316a66d70862f256565 Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Sun, 19 Apr 2026 00:21:59 +0200 Subject: [PATCH 08/14] test - don't worry, CodeRabbit --- .github/workflows/docker-ci.yml | 116 +++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 3ec8772f4..a0917cd98 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -26,6 +26,8 @@ jobs: clean_core_tag: ${{ steps.meta.outputs.clean_core_tag }} tags: ${{ steps.meta.outputs.tags }} cache_ref: ${{ steps.meta.outputs.cache_ref }} + is_fork: ${{ steps.meta.outputs.is_fork }} + artifact_name: ${{ steps.meta.outputs.artifact_name }} steps: - name: Checkout @@ -37,6 +39,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - name: Login to GitHub Container Registry + if: github.event.pull_request.head.repo.fork == false uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 with: registry: ghcr.io @@ -44,7 +47,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && github.event.pull_request.head.repo.fork == false uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -53,6 +56,7 @@ jobs: - name: Compute tags id: meta run: | + IS_FORK="${{ github.event.pull_request.head.repo.fork }}" GHCR="ghcr.io/$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')/img2num-dev" DH="${{ env.DH_IMAGE }}" EVENT="${{ github.event_name }}" @@ -61,8 +65,16 @@ jobs: PR="${{ github.event.pull_request.number }}" CLEAN_CORE_TAG="pr-${PR}" - TAGS="${GHCR}:pr-${PR}" - CACHE_REF="${GHCR}:cache-pr-${PR}" + ARTIFACT_NAME="docker-image-pr-${PR}" + + if [[ "$IS_FORK" == "true" ]]; then + # Forks: tag the image locally only (no registry push) + TAGS="img2num-dev:pr-${PR}" + CACHE_REF="" + else + TAGS="${GHCR}:pr-${PR}" + CACHE_REF="${GHCR}:cache-pr-${PR}" + fi elif [[ "$EVENT" == "push" && "${{ github.ref_type }}" == "tag" ]]; then VERSION="${GITHUB_REF_NAME}" @@ -70,18 +82,23 @@ jobs: CLEAN_CORE_TAG="$VERSION" TAGS="${GHCR}:${VERSION},${GHCR}:latest,${DH}:${VERSION},${DH}:latest" CACHE_REF="${GHCR}:buildcache" + ARTIFACT_NAME="" else CLEAN_CORE_TAG="main" TAGS="${GHCR}:main,${GHCR}:latest,${DH}:main,${DH}:latest" CACHE_REF="${GHCR}:buildcache" + ARTIFACT_NAME="" fi - echo "clean_core_tag=$CLEAN_CORE_TAG" >> $GITHUB_OUTPUT - echo "tags=$TAGS" >> $GITHUB_OUTPUT - echo "cache_ref=$CACHE_REF" >> $GITHUB_OUTPUT + echo "clean_core_tag=$CLEAN_CORE_TAG" >> $GITHUB_OUTPUT + echo "tags=$TAGS" >> $GITHUB_OUTPUT + echo "cache_ref=$CACHE_REF" >> $GITHUB_OUTPUT + echo "is_fork=true" >> $GITHUB_OUTPUT + echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT - - name: Build & Push Docker image + - name: Build & Push Docker image (non-fork) + if: github.event.pull_request.head.repo.fork == false uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f with: context: . @@ -93,6 +110,42 @@ jobs: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache cache-to: type=registry,ref=${{ steps.meta.outputs.cache_ref }},mode=max + - name: Build Docker image (fork — local load only) + if: github.event.pull_request.head.repo.fork == true + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f + with: + context: . + file: Dockerfile.dev + # Forks cannot push to GHCR, so we load into the local Docker daemon + # and export via `docker save` below. + push: false + load: true + tags: ${{ steps.meta.outputs.tags }} + # No registry cache available for forks; fall back to the default + # Buildx inline/local cache so layer reuse still works across re-runs. + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Export fork image to tar + if: github.event.pull_request.head.repo.fork == true + run: | + IMAGE_TAG="${{ steps.meta.outputs.tags }}" + OUTPUT_FILE="docker-image.tar" + echo "Saving ${IMAGE_TAG} → ${OUTPUT_FILE}" + docker save "${IMAGE_TAG}" | gzip > "${OUTPUT_FILE}.gz" + echo "Compressed size: $(du -sh ${OUTPUT_FILE}.gz | cut -f1)" + + - name: Upload fork image artifact + if: github.event.pull_request.head.repo.fork == true + uses: actions/upload-artifact@v4 + with: + # Include the PR number in the artifact name so reviewers can + # distinguish artifacts when multiple fork PRs are open at once. + name: ${{ steps.meta.outputs.artifact_name }} + path: docker-image.tar.gz + # Retain for the lifetime of the PR review window; adjust as needed. + retention-days: 14 + notify: needs: build @@ -104,6 +157,8 @@ jobs: env: CLEAN_CORE_TAG: ${{ needs.build.outputs.clean_core_tag }} + IS_FORK: ${{ needs.build.outputs.is_fork }} + ARTIFACT_NAME: ${{ needs.build.outputs.artifact_name }} steps: # Avoid chat spamming by checking for an existing comment @@ -128,8 +183,9 @@ jobs: echo "lower_owner_name=${LOWER_OWNER_NAME}" >> $GITHUB_OUTPUT echo "ghcr_image=${GHCR_IMAGE}" >> $GITHUB_OUTPUT - - name: Create Docker comment - if: steps.fc.outputs.comment-id == '' + # ── Non-fork PR comment ─────────────────────────────────────────────── + - name: Create Docker comment (non-fork) + if: steps.fc.outputs.comment-id == '' && env.IS_FORK == 'false' uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 with: issue-number: ${{ github.event.pull_request.number }} @@ -145,6 +201,48 @@ jobs: IMG2NUM_IMAGE=${{ steps.img.outputs.ghcr_image }}:${{ env.CLEAN_CORE_TAG }} ./img2num sh ``` + # ── Fork PR comment ─────────────────────────────────────────────────── + # Forks cannot push to GHCR, so we guide reviewers to download the + # artifact and load it into their local Docker daemon manually. + - name: Create Docker comment (fork) + if: steps.fc.outputs.comment-id == '' && env.IS_FORK == 'true' + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + + ## 🐳 Docker image built successfully! (fork PR) + + > [!NOTE] + > This PR comes from a fork, so the image cannot be pushed to GHCR. + > A compressed image tarball has been uploaded as a GitHub Actions artifact instead. + + ### 1 — Download the artifact + + Go to the **[workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})** + and download **`${{ env.ARTIFACT_NAME }}`** from the *Artifacts* section, or use the GitHub CLI: + + ```bash + gh run download ${{ github.run_id }} \ + --repo ${{ github.repository }} \ + --name "${{ env.ARTIFACT_NAME }}" + ``` + + ### 2 — Load the image + + ```bash + docker load < docker-image.tar.gz + ``` + + ### 3 — Run it + + ```bash + IMG2NUM_IMAGE=img2num-dev:${{ env.CLEAN_CORE_TAG }} ./img2num sh + ``` + + > [!TIP] + > The artifact is retained for **14 days**. Re-run the workflow if it has expired. + cleanup: if: github.event.action == 'closed' From b1d6c912e8ef499b70c527dab9802ee2252f1dfd Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Tue, 21 Apr 2026 20:52:06 +0200 Subject: [PATCH 09/14] ci(docker): update docker-ci.yml to require commands to build and push image --- .github/workflows/ci.yml | 34 ++-- .github/workflows/docker-ci.yml | 279 ++++++++++++++++---------------- 2 files changed, 157 insertions(+), 156 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc5db25b6..f3ab201ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,21 +20,29 @@ jobs: - name: Set image id: set run: | - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "image=ghcr.io/ryan-millard/img2num-dev:pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + MAIN_IMAGE="image=ghcr.io/ryan-millard/img2num-dev:main" + PR_TAG="pr-${{ github.event.pull_request.number }}" + PR_IMAGE="ryan-millard/img2num-dev:$PR_TAG" + GHCR_IMAGE="image=ghcr.io/$PR_IMAGE" + + GHCR_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + "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/$PR_IMAGE/tags/$PR_TAG") + + if [ "$GHCR_STATUS" == "200" ]; then + echo "image=$GHCR_IMAGE" >> $GITHUB_OUTPUT + elif [ "$DH_STATUS" == 200 ]; then + echo "image=$PR_IMAGE" >> $GITHUB_OUTPUT else - echo "image=ghcr.io/ryan-millard/img2num-dev:main" >> $GITHUB_OUTPUT + echo "image=$MAIN_IMAGE" >> $GITHUB_OUTPUT fi - wait-for-image: - needs: set-image - name: Wait for Docker image to be pushed to GHCR - uses: ./.github/workflows/wait-for-docker-image.yml - with: - ghcr_image: ${{ needs.set-image.outputs.image }} lint: name: Lint & Validate Code - needs: [set-image, wait-for-image] + needs: [set-image] runs-on: ubuntu-latest container: image: ${{ needs.set-image.outputs.image }} @@ -61,14 +69,14 @@ jobs: cmake-build: name: Build C/C++ uses: ./.github/workflows/cmake-build.yml - needs: [set-image, wait-for-image] + needs: [set-image] with: image: ${{ needs.set-image.outputs.image }} build-react-app: name: Build React App uses: ./.github/workflows/build-react-app.yml - needs: [set-image, cmake-build, wait-for-image] + needs: [set-image, cmake-build] with: image: ${{ needs.set-image.outputs.image }} wasm-artifacts-path: packages/js/build-wasm/ @@ -76,7 +84,7 @@ jobs: build-docs: name: Build Documentation Site uses: ./.github/workflows/build-docs.yml - needs: [set-image, wait-for-image] + needs: [set-image] with: image: ${{ needs.set-image.outputs.image }} secrets: diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index a0917cd98..1024013c8 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -1,54 +1,137 @@ 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 - packages: write # needed for GHCR - pull-requests: write # needed for PR comments + packages: write + pull-requests: write env: GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/img2num-dev DH_IMAGE: ryanmillard/img2num-dev jobs: + 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 + 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 }} + run: | + if [[ "${{ github.event_name }}" == "issue_comment" ]]; then + PR_NUMBER="${{ github.event.issue.number }}" + SHA=$(gh api repos/${{ github.repository }}/pulls/${PR_NUMBER} --jq '.head.sha') + BODY="${{ github.event.comment.body }}" + if [[ "$BODY" == /docker-cleanup* ]]; then + COMMAND="cleanup" + else + COMMAND="build" + fi + echo "pr_number=${PR_NUMBER}" >> $GITHUB_OUTPUT + echo "sha=${SHA}" >> $GITHUB_OUTPUT + echo "command=${COMMAND}" >> $GITHUB_OUTPUT + else + echo "pr_number=" >> $GITHUB_OUTPUT + echo "sha=${{ github.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 + echo "authorized=true" >> $GITHUB_OUTPUT + 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: - if: github.event.action != 'closed' + 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 outputs: clean_core_tag: ${{ steps.meta.outputs.clean_core_tag }} - tags: ${{ steps.meta.outputs.tags }} - cache_ref: ${{ steps.meta.outputs.cache_ref }} - is_fork: ${{ steps.meta.outputs.is_fork }} - artifact_name: ${{ steps.meta.outputs.artifact_name }} + 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 - if: github.event.pull_request.head.repo.fork == false - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7499ccc52663121 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: github.event_name != 'pull_request' && github.event.pull_request.head.repo.fork == false - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + if: github.event_name == 'push' + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7499ccc52663121 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -56,49 +139,33 @@ jobs: - name: Compute tags id: meta run: | - IS_FORK="${{ github.event.pull_request.head.repo.fork }}" GHCR="ghcr.io/$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')/img2num-dev" 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}" - ARTIFACT_NAME="docker-image-pr-${PR}" - - if [[ "$IS_FORK" == "true" ]]; then - # Forks: tag the image locally only (no registry push) - TAGS="img2num-dev:pr-${PR}" - CACHE_REF="" - else - TAGS="${GHCR}:pr-${PR}" - CACHE_REF="${GHCR}:cache-pr-${PR}" - fi + 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="${GHCR}:${VERSION},${GHCR}:latest,${DH}:${VERSION},${DH}:latest" CACHE_REF="${GHCR}:buildcache" - ARTIFACT_NAME="" else CLEAN_CORE_TAG="main" TAGS="${GHCR}:main,${GHCR}:latest,${DH}:main,${DH}:latest" CACHE_REF="${GHCR}:buildcache" - ARTIFACT_NAME="" fi - echo "clean_core_tag=$CLEAN_CORE_TAG" >> $GITHUB_OUTPUT - echo "tags=$TAGS" >> $GITHUB_OUTPUT - echo "cache_ref=$CACHE_REF" >> $GITHUB_OUTPUT - echo "is_fork=true" >> $GITHUB_OUTPUT - echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + echo "clean_core_tag=$CLEAN_CORE_TAG" >> $GITHUB_OUTPUT + echo "tags=$TAGS" >> $GITHUB_OUTPUT + echo "cache_ref=$CACHE_REF" >> $GITHUB_OUTPUT - - name: Build & Push Docker image (non-fork) - if: github.event.pull_request.head.repo.fork == false + - name: Build & Push Docker image uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f with: context: . @@ -110,85 +177,38 @@ jobs: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache cache-to: type=registry,ref=${{ steps.meta.outputs.cache_ref }},mode=max - - name: Build Docker image (fork — local load only) - if: github.event.pull_request.head.repo.fork == true - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f - with: - context: . - file: Dockerfile.dev - # Forks cannot push to GHCR, so we load into the local Docker daemon - # and export via `docker save` below. - push: false - load: true - tags: ${{ steps.meta.outputs.tags }} - # No registry cache available for forks; fall back to the default - # Buildx inline/local cache so layer reuse still works across re-runs. - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Export fork image to tar - if: github.event.pull_request.head.repo.fork == true - run: | - IMAGE_TAG="${{ steps.meta.outputs.tags }}" - OUTPUT_FILE="docker-image.tar" - echo "Saving ${IMAGE_TAG} → ${OUTPUT_FILE}" - docker save "${IMAGE_TAG}" | gzip > "${OUTPUT_FILE}.gz" - echo "Compressed size: $(du -sh ${OUTPUT_FILE}.gz | cut -f1)" - - - name: Upload fork image artifact - if: github.event.pull_request.head.repo.fork == true - uses: actions/upload-artifact@v4 - with: - # Include the PR number in the artifact name so reviewers can - # distinguish artifacts when multiple fork PRs are open at once. - name: ${{ steps.meta.outputs.artifact_name }} - path: docker-image.tar.gz - # Retain for the lifetime of the PR review window; adjust as needed. - retention-days: 14 - - 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: CLEAN_CORE_TAG: ${{ needs.build.outputs.clean_core_tag }} - IS_FORK: ${{ needs.build.outputs.is_fork }} - ARTIFACT_NAME: ${{ needs.build.outputs.artifact_name }} 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 != '' - run: | - echo "Found comment ID: ${{ steps.fc.outputs.comment-id }}" - - name: Compute lowercase image ref id: img run: | - # GHCR for some reason requires lowercase when pulling 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 + echo "ghcr_image=${GHCR_IMAGE}" >> $GITHUB_OUTPUT - # ── Non-fork PR comment ─────────────────────────────────────────────── - - name: Create Docker comment (non-fork) - if: steps.fc.outputs.comment-id == '' && env.IS_FORK == 'false' + - 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! @@ -201,55 +221,31 @@ jobs: IMG2NUM_IMAGE=${{ steps.img.outputs.ghcr_image }}:${{ env.CLEAN_CORE_TAG }} ./img2num sh ``` - # ── Fork PR comment ─────────────────────────────────────────────────── - # Forks cannot push to GHCR, so we guide reviewers to download the - # artifact and load it into their local Docker daemon manually. - - name: Create Docker comment (fork) - if: steps.fc.outputs.comment-id == '' && env.IS_FORK == 'true' - uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 + 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: - issue-number: ${{ github.event.pull_request.number }} - body: | - - ## 🐳 Docker image built successfully! (fork PR) - - > [!NOTE] - > This PR comes from a fork, so the image cannot be pushed to GHCR. - > A compressed image tarball has been uploaded as a GitHub Actions artifact instead. - - ### 1 — Download the artifact - - Go to the **[workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})** - and download **`${{ env.ARTIFACT_NAME }}`** from the *Artifacts* section, or use the GitHub CLI: - - ```bash - gh run download ${{ github.run_id }} \ - --repo ${{ github.repository }} \ - --name "${{ env.ARTIFACT_NAME }}" - ``` - - ### 2 — Load the image - - ```bash - docker load < docker-image.tar.gz - ``` - - ### 3 — Run it - - ```bash - IMG2NUM_IMAGE=img2num-dev:${{ env.CLEAN_CORE_TAG }} ./img2num sh - ``` - - > [!TIP] - > The artifact is retained for **14 days**. Re-run the workflow if it has expired. - + 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: - if: github.event.action == 'closed' + 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 from GHCR uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 @@ -257,9 +253,6 @@ jobs: package-name: img2num-dev package-type: container delete-untagged-versions: false - dry-run: true - # Deletes both pr-N and cache-pr-N tags - # $ anchors each alternative so pr-5 does not match pr-50, pr-51, etc. - # (See CodeRabbit's related review comment: https://github.com/Ryan-Millard/Img2Num/pull/312#discussion_r3105632912) + dry-run: false min-versions-to-keep: 0 - ignore-versions: "^(?!pr-${{ github.event.pull_request.number }}$|cache-pr-${{ github.event.pull_request.number }}$).*" + ignore-versions: "^(?!pr-${{ needs.guard.outputs.pr_number }}$|cache-pr-${{ needs.guard.outputs.pr_number }}$).*" From 47f45740b1d9744e31d4a91a6e38e9aeacb2c881 Mon Sep 17 00:00:00 2001 From: Ryan Millard <142347829+Ryan-Millard@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:13:19 +0200 Subject: [PATCH 10/14] fix(docker-ci): resolve code injection risk Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/docker-ci.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 1024013c8..b4bf1383c 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -44,12 +44,15 @@ jobs: 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 [[ "${{ github.event_name }}" == "issue_comment" ]]; then - PR_NUMBER="${{ github.event.issue.number }}" - SHA=$(gh api repos/${{ github.repository }}/pulls/${PR_NUMBER} --jq '.head.sha') - BODY="${{ github.event.comment.body }}" - if [[ "$BODY" == /docker-cleanup* ]]; then + if [[ "$EVENT_NAME" == "issue_comment" ]]; then + SHA=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha') + if [[ "$COMMENT_BODY" == /docker-cleanup* ]]; then COMMAND="cleanup" else COMMAND="build" @@ -59,7 +62,7 @@ jobs: echo "command=${COMMAND}" >> $GITHUB_OUTPUT else echo "pr_number=" >> $GITHUB_OUTPUT - echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT + echo "sha=${PUSH_SHA}" >> $GITHUB_OUTPUT echo "command=build" >> $GITHUB_OUTPUT fi From 7d83665b9e01aa5a78ae4a7207ffe44456063aa0 Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Tue, 21 Apr 2026 21:22:56 +0200 Subject: [PATCH 11/14] ci(docker): cleanup docker-ci.yml --- .github/workflows/docker-ci.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index b4bf1383c..f8b0976c4 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -6,16 +6,15 @@ on: push: branches: [main] tags: ["v*"] - pull_request: + pull_request_target: types: [closed] permissions: contents: read - packages: write - pull-requests: write + pull-requests: read env: - GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/img2num-dev + GHCR_IMAGE: ghcr.io/ryan-millard/img2num-dev DH_IMAGE: ryanmillard/img2num-dev jobs: @@ -34,6 +33,8 @@ jobs: ) ) runs-on: ubuntu-latest + permissions: + pull-requests: write outputs: pr_number: ${{ steps.resolve.outputs.pr_number }} sha: ${{ steps.resolve.outputs.sha }} @@ -109,7 +110,9 @@ jobs: 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 }} @@ -142,7 +145,7 @@ jobs: - name: Compute tags id: meta run: | - GHCR="ghcr.io/$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')/img2num-dev" + GHCR="${{ env.GHCR_IMAGE }}" DH="${{ env.DH_IMAGE }}" EVENT="${{ github.event_name }}" @@ -225,7 +228,7 @@ jobs: ``` cleanup-on-close: - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest permissions: packages: write From c73ef8fdfdd3f0f545d43e2523ce7f7ec6c7350b Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Tue, 21 Apr 2026 21:24:18 +0200 Subject: [PATCH 12/14] fix(docker-ci): accidental image name error --- .github/workflows/ci.yml | 26 +++++++++++++++----------- .github/workflows/docker-ci.yml | 4 ++-- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3ab201ba..edff2d9e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,24 +20,28 @@ jobs: - name: Set image id: set run: | - MAIN_IMAGE="image=ghcr.io/ryan-millard/img2num-dev:main" + MAIN_IMAGE="ghcr.io/ryan-millard/img2num-dev:main" PR_TAG="pr-${{ github.event.pull_request.number }}" - PR_IMAGE="ryan-millard/img2num-dev:$PR_TAG" - GHCR_IMAGE="image=ghcr.io/$PR_IMAGE" + 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 "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ - "https://ghcr.io/v2/ryan-millard/img2num-dev/manifests/$PR_TAG") + -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/$PR_IMAGE/tags/$PR_TAG") + "https://registry.hub.docker.com/v2/repositories/${REPO}/tags/${PR_TAG}/") - if [ "$GHCR_STATUS" == "200" ]; then - echo "image=$GHCR_IMAGE" >> $GITHUB_OUTPUT - elif [ "$DH_STATUS" == 200 ]; then - echo "image=$PR_IMAGE" >> $GITHUB_OUTPUT + 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" >> $GITHUB_OUTPUT + echo "image=${MAIN_IMAGE}" + echo "image=${MAIN_IMAGE}" >> $GITHUB_OUTPUT fi lint: diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index f8b0976c4..936435409 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -6,7 +6,7 @@ on: push: branches: [main] tags: ["v*"] - pull_request_target: + pull_request: types: [closed] permissions: @@ -228,7 +228,7 @@ jobs: ``` cleanup-on-close: - if: github.event_name == 'pull_request_target' + if: github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: packages: write From 7d6dae9916bcfd400cd32d077775b33c16a167eb Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Wed, 22 Apr 2026 00:17:25 +0200 Subject: [PATCH 13/14] fix(docker-ci): Checkout of untrusted code in trusted context --- .github/workflows/docker-ci.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 936435409..97275cc8a 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -52,15 +52,28 @@ jobs: PUSH_SHA: ${{ github.sha }} run: | if [[ "$EVENT_NAME" == "issue_comment" ]]; then - SHA=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha') 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 @@ -129,7 +142,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7499ccc52663121 + uses: docker/login-action@4a8376e001c7725687f0624d1c3698a3f6ab337e with: registry: ghcr.io username: ${{ github.actor }} @@ -137,7 +150,7 @@ jobs: - name: Login to Docker Hub if: github.event_name == 'push' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7499ccc52663121 + uses: docker/login-action@4a8376e001c7725687f0624d1c3698a3f6ab337e with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -176,7 +189,7 @@ jobs: with: context: . file: Dockerfile.dev - push: true + push: false tags: ${{ steps.meta.outputs.tags }} cache-from: | type=registry,ref=${{ steps.meta.outputs.cache_ref }} From 2e0cec5ce7c7d9458b51fc377da22c0e669f7edb Mon Sep 17 00:00:00 2001 From: Ryan-Millard Date: Wed, 22 Apr 2026 01:43:04 +0200 Subject: [PATCH 14/14] ci(docker): restrict to only Krasner & Ryan-Millard with permission to build --- .github/workflows/docker-ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 97275cc8a..a7ea29c1e 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -91,7 +91,14 @@ jobs: --jq '.permission') echo "Permission level: $PERMISSION" if [[ "$PERMISSION" == "write" || "$PERMISSION" == "admin" || "$PERMISSION" == "maintain" ]]; then - echo "authorized=true" >> $GITHUB_OUTPUT + + # 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