From 68e43072300af1a1615e8beb396c032d31be5d78 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:20:10 +0000 Subject: [PATCH 1/6] feat: publish test images for pull requests --- .github/workflows/publish-pr-images.yml | 218 ++++++++++++++++++++ CONTRIBUTING.md | 10 + scripts/release/__tests__/workflow.test.mjs | 67 +++++- 3 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish-pr-images.yml diff --git a/.github/workflows/publish-pr-images.yml b/.github/workflows/publish-pr-images.yml new file mode 100644 index 000000000..6168ee44c --- /dev/null +++ b/.github/workflows/publish-pr-images.yml @@ -0,0 +1,218 @@ +name: Publish PR Images + +on: + issue_comment: + types: [created] + +concurrency: + group: publish-pr-images-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + prepare: + name: Resolve PR build metadata + if: >- + github.event.issue.pull_request && + github.event.issue.state == 'open' && + github.event.comment.body == '/publish-images' && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + owner: ${{ steps.pr.outputs.owner }} + sha: ${{ steps.pr.outputs.sha }} + version: ${{ steps.pr.outputs.version }} + steps: + - name: Resolve PR head + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.issue.number }} + shell: bash + run: | + set -euo pipefail + pr="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" + sha="$(jq -r '.head.sha' <<<"$pr")" + state="$(jq -r '.state' <<<"$pr")" + [ "$state" = 'open' ] || { + echo "Pull request #${PR_NUMBER} is not open." >&2 + exit 1 + } + case "$sha" in + '' | *[!0-9a-f]*) + echo "Invalid pull request head SHA: $sha" >&2 + exit 1 + ;; + esac + [ "${#sha}" -eq 40 ] || { + echo "Invalid pull request head SHA length: $sha" >&2 + exit 1 + } + + owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" + echo "owner=$owner" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "version=pr-${PR_NUMBER}-${sha:0:8}" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.app }} (amd64) + needs: prepare + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - app: app + dockerfile: .docker/app/Dockerfile + target: runtime-app + image: roomote-app + - app: worker + dockerfile: apps/worker/Dockerfile + target: runtime + image: roomote-worker + steps: + - name: Checkout PR head + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.prepare.outputs.sha }} + persist-credentials: false + + - name: Read product version + id: product + shell: bash + run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" + + - name: Set up Docker builder + uses: useblacksmith/setup-docker-builder@47a5d0102cc44712a17a633c2599f755008cc40e # v1 + + # This job executes PR-controlled Dockerfiles without any package-write + # token. The privileged publisher below only imports the finished image. + - name: Build ${{ matrix.app }} image + uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 + with: + context: . + file: ${{ matrix.dockerfile }} + target: ${{ matrix.target }} + platforms: linux/amd64 + tags: local/${{ matrix.image }}:${{ needs.prepare.outputs.version }} + outputs: type=docker,dest=/tmp/${{ matrix.image }}.tar + build-args: | + R_APP_ENV=preview + RELEASE_VERSION=${{ needs.prepare.outputs.version }} + RELEASE_PRODUCT_VERSION=${{ steps.product.outputs.version }} + + - name: Upload image artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pr-image-${{ matrix.app }} + path: /tmp/${{ matrix.image }}.tar + compression-level: 0 + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish PR images + needs: [prepare, build] + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + packages: write + pull-requests: read + outputs: + mutable_updated: ${{ steps.images.outputs.mutable_updated }} + steps: + - name: Download image artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: pr-image-* + path: /tmp/images + merge-multiple: true + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Import and publish images + id: images + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ needs.prepare.outputs.owner }} + PR_NUMBER: ${{ github.event.issue.number }} + SHA: ${{ needs.prepare.outputs.sha }} + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: | + set -euo pipefail + mutable_version="pr-${PR_NUMBER}" + echo 'mutable_updated=false' >> "$GITHUB_OUTPUT" + + for image in roomote-app roomote-worker; do + docker load --input "/tmp/images/${image}.tar" + local_ref="local/${image}:${VERSION}" + immutable_ref="ghcr.io/${OWNER}/${image}:${VERSION}" + docker tag "$local_ref" "$immutable_ref" + docker push "$immutable_ref" + done + + current_sha="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha')" + if [ "$current_sha" != "$SHA" ]; then + echo "PR advanced to $current_sha; leaving movable tags unchanged." + exit 0 + fi + + for image in roomote-app roomote-worker; do + immutable_ref="ghcr.io/${OWNER}/${image}:${VERSION}" + mutable_ref="ghcr.io/${OWNER}/${image}:${mutable_version}" + docker tag "$immutable_ref" "$mutable_ref" + docker push "$mutable_ref" + done + echo 'mutable_updated=true' >> "$GITHUB_OUTPUT" + + comment: + name: Comment PR image references + needs: [prepare, publish] + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Post published image references + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ needs.prepare.outputs.owner }} + VERSION: ${{ needs.prepare.outputs.version }} + PR_NUMBER: ${{ github.event.issue.number }} + MUTABLE_UPDATED: ${{ needs.publish.outputs.mutable_updated }} + shell: bash + run: | + set -euo pipefail + mutable_version="pr-${PR_NUMBER}" + if [ "$MUTABLE_UPDATED" = 'true' ]; then + mutable_note="$(cat <-` +image references and updates the movable `pr-` tags. This works for +fork pull requests: PR-controlled code runs in an unprivileged build job, while +a separate publisher job receives only the completed image artifacts and the +package-write token. PR preview images currently target `linux/amd64`. + ### How a release ships 1. Merge work to `develop` (squash), adding changesets for user-visible changes diff --git a/scripts/release/__tests__/workflow.test.mjs b/scripts/release/__tests__/workflow.test.mjs index 73a2c160d..0982bda92 100644 --- a/scripts/release/__tests__/workflow.test.mjs +++ b/scripts/release/__tests__/workflow.test.mjs @@ -36,8 +36,14 @@ test('release workflow keeps promotion as the only automated PR gate', () => { 2, ); assert.doesNotMatch(promoteScript, /git fetch origin main/); - assert.match(promoteScript, /the candidate reached main while this refresh was running/); - assert.match(promoteScript, /the Promote PR closed while this refresh was running/); + assert.match( + promoteScript, + /the candidate reached main while this refresh was running/, + ); + assert.match( + promoteScript, + /the Promote PR closed while this refresh was running/, + ); assert.match(promoteScript, /release_sha="\$bump_sha"/); assert.match( promoteScript, @@ -71,3 +77,60 @@ test('GHCR release workflow announces only newly created releases in Discord', ( assert.match(announceRelease.run, /build-discord-release-payload\.mjs/); assert.match(announceRelease.run, /--retry-all-errors/); }); + +test('GHCR workflow publishes explicitly requested pull request images safely', () => { + const workflow = YAML.parse( + readFileSync( + join(repoRoot, '.github/workflows/publish-pr-images.yml'), + 'utf8', + ), + ); + + assert.deepEqual(workflow.on.issue_comment.types, ['created']); + assert.match(workflow.jobs.prepare.if, /\/publish-images/); + assert.match(workflow.jobs.prepare.if, /state == 'open'/); + assert.match(workflow.jobs.prepare.if, /OWNER/); + assert.match(workflow.jobs.prepare.if, /MEMBER/); + assert.match(workflow.jobs.prepare.if, /COLLABORATOR/); + + const build = workflow.jobs.build; + assert.equal(build.permissions.packages, undefined); + const buildCheckout = build.steps.find( + (step) => step.name === 'Checkout PR head', + ); + assert.equal(buildCheckout.with.ref, '${{ needs.prepare.outputs.sha }}'); + assert.equal(buildCheckout.with['persist-credentials'], false); + + const buildImage = build.steps.find((step) => step.name.startsWith('Build ')); + assert.match(buildImage.with.outputs, /type=docker/); + assert.equal(buildImage.with.push, undefined); + + const publisher = workflow.jobs.publish; + assert.equal(publisher.permissions.packages, 'write'); + assert.equal( + publisher.steps.some( + (step) => + step.uses?.startsWith('actions/checkout') || + step.uses?.startsWith('./'), + ), + false, + ); + const publishScript = publisher.steps.find( + (step) => step.name === 'Import and publish images', + ).run; + assert.match(publishScript, /docker load/); + assert.match(publishScript, /current_sha/); + assert.match(publishScript, /roomote-app roomote-worker/); + assert.equal( + publisher.outputs.mutable_updated, + '${{ steps.images.outputs.mutable_updated }}', + ); + + const commentJob = workflow.jobs.comment; + assert.deepEqual(commentJob.needs, ['prepare', 'publish']); + assert.equal(commentJob.permissions['pull-requests'], 'write'); + assert.match(commentJob.steps[0].run, /roomote-app/); + assert.match(commentJob.steps[0].run, /roomote-worker/); + assert.match(commentJob.steps[0].run, /Movable references/); + assert.match(commentJob.steps[0].run, /MUTABLE_UPDATED/); +}); From 68b3bbab725d29aa344618df1bafafe80990eb17 Mon Sep 17 00:00:00 2001 From: Tom <20028678+tomny-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:37:20 +0000 Subject: [PATCH 2/6] refactor: reuse CI builds for PR images --- .github/workflows/CI.yml | 62 ++++++++++ .github/workflows/publish-pr-images.yml | 120 ++++++++++---------- CONTRIBUTING.md | 7 +- scripts/release/__tests__/workflow.test.mjs | 50 ++++---- 4 files changed, 157 insertions(+), 82 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index abfb42436..2fe01538b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -21,6 +21,7 @@ jobs: outputs: docs_only: ${{ steps.changed-paths.outputs.docs_only }} worker_image_affected: ${{ steps.changed-paths.outputs.worker_image_affected }} + pr_image_version: ${{ steps.changed-paths.outputs.pr_image_version }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -32,6 +33,8 @@ jobs: run: | set -euo pipefail + echo 'pr_image_version=' >> "$GITHUB_OUTPUT" + # Fail safe: when we cannot compute a reliable diff (manual dispatch, # force-push over a gone base, or an empty diff), build everything. emit_build_all() { @@ -47,6 +50,7 @@ jobs: if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then base="${{ github.event.pull_request.base.sha }}" head="${{ github.event.pull_request.head.sha }}" + echo "pr_image_version=pr-${{ github.event.pull_request.number }}-${head:0:8}" >> "$GITHUB_OUTPUT" changed_files="$(git diff --name-only "$base...$head")" else before="${{ github.event.before }}" @@ -306,6 +310,7 @@ jobs: - name: Set up Docker builder uses: useblacksmith/setup-docker-builder@47a5d0102cc44712a17a633c2599f755008cc40e # v1 - name: Build ${{ matrix.app }} Dockerfile (${{ matrix.arch }}) + if: ${{ github.event_name != 'pull_request' || matrix.arch != 'amd64' }} uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 with: context: . @@ -314,6 +319,34 @@ jobs: platforms: linux/${{ matrix.arch }} push: false build-args: R_APP_ENV=development + - name: Read product version + if: ${{ github.event_name == 'pull_request' && matrix.arch == 'amd64' }} + id: app-product + shell: bash + run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" + - name: Build publishable ${{ matrix.app }} image (${{ matrix.arch }}) + if: ${{ github.event_name == 'pull_request' && matrix.arch == 'amd64' }} + uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 + with: + context: . + file: ${{ matrix.dockerfile }} + target: ${{ matrix.target }} + platforms: linux/${{ matrix.arch }} + tags: local/roomote-${{ matrix.app }}:${{ needs.changes.outputs.pr_image_version }} + outputs: type=docker,dest=/tmp/roomote-${{ matrix.app }}.tar + build-args: | + R_APP_ENV=preview + RELEASE_VERSION=${{ needs.changes.outputs.pr_image_version }} + RELEASE_PRODUCT_VERSION=${{ steps.app-product.outputs.version }} + - name: Upload publishable ${{ matrix.app }} image + if: ${{ github.event_name == 'pull_request' && matrix.arch == 'amd64' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pr-image-${{ matrix.app }} + path: /tmp/roomote-${{ matrix.app }}.tar + compression-level: 0 + if-no-files-found: error + retention-days: 1 # The worker image builds FROM ubuntu and copies no monorepo source, so it is # skipped unless its own Dockerfile or build context changed (see the @@ -347,6 +380,7 @@ jobs: - name: Set up Docker builder uses: useblacksmith/setup-docker-builder@47a5d0102cc44712a17a633c2599f755008cc40e # v1 - name: Build ${{ matrix.app }} Dockerfile (${{ matrix.arch }}) + if: ${{ github.event_name != 'pull_request' || matrix.arch != 'amd64' }} uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 with: context: . @@ -355,6 +389,34 @@ jobs: platforms: linux/${{ matrix.arch }} push: false build-args: R_APP_ENV=development + - name: Read product version + if: ${{ github.event_name == 'pull_request' && matrix.arch == 'amd64' }} + id: worker-product + shell: bash + run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" + - name: Build publishable ${{ matrix.app }} image (${{ matrix.arch }}) + if: ${{ github.event_name == 'pull_request' && matrix.arch == 'amd64' }} + uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 + with: + context: . + file: ${{ matrix.dockerfile }} + target: ${{ matrix.target }} + platforms: linux/${{ matrix.arch }} + tags: local/roomote-${{ matrix.app }}:${{ needs.changes.outputs.pr_image_version }} + outputs: type=docker,dest=/tmp/roomote-${{ matrix.app }}.tar + build-args: | + R_APP_ENV=preview + RELEASE_VERSION=${{ needs.changes.outputs.pr_image_version }} + RELEASE_PRODUCT_VERSION=${{ steps.worker-product.outputs.version }} + - name: Upload publishable ${{ matrix.app }} image + if: ${{ github.event_name == 'pull_request' && matrix.arch == 'amd64' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pr-image-${{ matrix.app }} + path: /tmp/roomote-${{ matrix.app }}.tar + compression-level: 0 + if-no-files-found: error + retention-days: 1 deployment-config: name: Deployment artifacts diff --git a/.github/workflows/publish-pr-images.yml b/.github/workflows/publish-pr-images.yml index 6168ee44c..1573b8137 100644 --- a/.github/workflows/publish-pr-images.yml +++ b/.github/workflows/publish-pr-images.yml @@ -18,12 +18,16 @@ jobs: contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) runs-on: ubuntu-latest permissions: + actions: read contents: read pull-requests: read outputs: owner: ${{ steps.pr.outputs.owner }} sha: ${{ steps.pr.outputs.sha }} version: ${{ steps.pr.outputs.version }} + run_id: ${{ steps.pr.outputs.run_id }} + worker_artifact: ${{ steps.pr.outputs.worker_artifact }} + base_version: ${{ steps.pr.outputs.base_version }} steps: - name: Resolve PR head id: pr @@ -36,6 +40,8 @@ jobs: pr="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" sha="$(jq -r '.head.sha' <<<"$pr")" state="$(jq -r '.state' <<<"$pr")" + base_ref="$(jq -r '.base.ref' <<<"$pr")" + base_sha="$(jq -r '.base.sha' <<<"$pr")" [ "$state" = 'open' ] || { echo "Pull request #${PR_NUMBER} is not open." >&2 exit 1 @@ -50,75 +56,56 @@ jobs: echo "Invalid pull request head SHA length: $sha" >&2 exit 1 } + case "$base_ref" in + develop | main) ;; + *) + echo "Unsupported pull request base branch: $base_ref" >&2 + exit 1 + ;; + esac + + runs="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/workflows/CI.yml/runs?event=pull_request&per_page=100")" + run="$(jq -c \ + --argjson number "$PR_NUMBER" \ + --arg sha "$sha" \ + --arg base_sha "$base_sha" \ + '[.[].workflow_runs[] | select(any(.pull_requests[]?; .number == $number and .head.sha == $sha and .base.sha == $base_sha))] | sort_by(.run_number) | reverse | .[0] // empty' \ + <<<"$runs")" + [ -n "$run" ] || { + echo "No CI run found for PR #${PR_NUMBER} at ${sha}." >&2 + exit 1 + } + status="$(jq -r '.status' <<<"$run")" + conclusion="$(jq -r '.conclusion // ""' <<<"$run")" + [ "$status" = 'completed' ] && [ "$conclusion" = 'success' ] || { + echo "Latest CI run for ${sha} is ${status}/${conclusion:-pending}; wait for CI to pass." >&2 + exit 1 + } + run_id="$(jq -r '.id' <<<"$run")" + artifacts="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100")" + jq -e '.artifacts[] | select(.name == "pr-image-app" and .expired == false)' <<<"$artifacts" >/dev/null || { + echo "CI run ${run_id} has no publishable app image artifact." >&2 + exit 1 + } + worker_artifact=false + if jq -e '.artifacts[] | select(.name == "pr-image-worker" and .expired == false)' <<<"$artifacts" >/dev/null; then + worker_artifact=true + fi owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" echo "owner=$owner" >> "$GITHUB_OUTPUT" echo "sha=$sha" >> "$GITHUB_OUTPUT" echo "version=pr-${PR_NUMBER}-${sha:0:8}" >> "$GITHUB_OUTPUT" - - build: - name: Build ${{ matrix.app }} (amd64) - needs: prepare - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - - app: app - dockerfile: .docker/app/Dockerfile - target: runtime-app - image: roomote-app - - app: worker - dockerfile: apps/worker/Dockerfile - target: runtime - image: roomote-worker - steps: - - name: Checkout PR head - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ needs.prepare.outputs.sha }} - persist-credentials: false - - - name: Read product version - id: product - shell: bash - run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" - - - name: Set up Docker builder - uses: useblacksmith/setup-docker-builder@47a5d0102cc44712a17a633c2599f755008cc40e # v1 - - # This job executes PR-controlled Dockerfiles without any package-write - # token. The privileged publisher below only imports the finished image. - - name: Build ${{ matrix.app }} image - uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 - with: - context: . - file: ${{ matrix.dockerfile }} - target: ${{ matrix.target }} - platforms: linux/amd64 - tags: local/${{ matrix.image }}:${{ needs.prepare.outputs.version }} - outputs: type=docker,dest=/tmp/${{ matrix.image }}.tar - build-args: | - R_APP_ENV=preview - RELEASE_VERSION=${{ needs.prepare.outputs.version }} - RELEASE_PRODUCT_VERSION=${{ steps.product.outputs.version }} - - - name: Upload image artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: pr-image-${{ matrix.app }} - path: /tmp/${{ matrix.image }}.tar - compression-level: 0 - if-no-files-found: error - retention-days: 1 + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + echo "worker_artifact=$worker_artifact" >> "$GITHUB_OUTPUT" + echo "base_version=${base_ref}-${base_sha:0:8}" >> "$GITHUB_OUTPUT" publish: name: Publish PR images - needs: [prepare, build] + needs: prepare runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: + actions: read contents: read packages: write pull-requests: read @@ -131,6 +118,8 @@ jobs: pattern: pr-image-* path: /tmp/images merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ needs.prepare.outputs.run_id }} - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 @@ -147,14 +136,25 @@ jobs: PR_NUMBER: ${{ github.event.issue.number }} SHA: ${{ needs.prepare.outputs.sha }} VERSION: ${{ needs.prepare.outputs.version }} + WORKER_ARTIFACT: ${{ needs.prepare.outputs.worker_artifact }} + BASE_VERSION: ${{ needs.prepare.outputs.base_version }} shell: bash run: | set -euo pipefail mutable_version="pr-${PR_NUMBER}" echo 'mutable_updated=false' >> "$GITHUB_OUTPUT" + docker load --input '/tmp/images/roomote-app.tar' + if [ "$WORKER_ARTIFACT" = 'true' ]; then + docker load --input '/tmp/images/roomote-worker.tar' + else + docker pull "ghcr.io/${OWNER}/roomote-worker:${BASE_VERSION}" + docker tag \ + "ghcr.io/${OWNER}/roomote-worker:${BASE_VERSION}" \ + "local/roomote-worker:${VERSION}" + fi + for image in roomote-app roomote-worker; do - docker load --input "/tmp/images/${image}.tar" local_ref="local/${image}:${VERSION}" immutable_ref="ghcr.io/${OWNER}/${image}:${VERSION}" docker tag "$local_ref" "$immutable_ref" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ced5b2efd..a2af335d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,9 +54,10 @@ A repository owner, member, or collaborator can comment `/publish-images` on an open pull request to publish preview builds of `roomote-app` and `roomote-worker`. The workflow replies with immutable `pr--` image references and updates the movable `pr-` tags. This works for -fork pull requests: PR-controlled code runs in an unprivileged build job, while -a separate publisher job receives only the completed image artifacts and the -package-write token. PR preview images currently target `linux/amd64`. +fork pull requests: the regular unprivileged CI build exports short-lived image +artifacts, while a separate publisher job receives only those completed +artifacts and the package-write token. PR preview images currently target +`linux/amd64` and require a successful CI run for the current head commit. ### How a release ships diff --git a/scripts/release/__tests__/workflow.test.mjs b/scripts/release/__tests__/workflow.test.mjs index 0982bda92..717c6e9f8 100644 --- a/scripts/release/__tests__/workflow.test.mjs +++ b/scripts/release/__tests__/workflow.test.mjs @@ -79,33 +79,44 @@ test('GHCR release workflow announces only newly created releases in Discord', ( }); test('GHCR workflow publishes explicitly requested pull request images safely', () => { - const workflow = YAML.parse( + const publishWorkflow = YAML.parse( readFileSync( join(repoRoot, '.github/workflows/publish-pr-images.yml'), 'utf8', ), ); + const ciWorkflow = YAML.parse( + readFileSync(join(repoRoot, '.github/workflows/CI.yml'), 'utf8'), + ); - assert.deepEqual(workflow.on.issue_comment.types, ['created']); - assert.match(workflow.jobs.prepare.if, /\/publish-images/); - assert.match(workflow.jobs.prepare.if, /state == 'open'/); - assert.match(workflow.jobs.prepare.if, /OWNER/); - assert.match(workflow.jobs.prepare.if, /MEMBER/); - assert.match(workflow.jobs.prepare.if, /COLLABORATOR/); + assert.deepEqual(publishWorkflow.on.issue_comment.types, ['created']); + assert.match(publishWorkflow.jobs.prepare.if, /\/publish-images/); + assert.match(publishWorkflow.jobs.prepare.if, /state == 'open'/); + assert.match(publishWorkflow.jobs.prepare.if, /OWNER/); + assert.match(publishWorkflow.jobs.prepare.if, /MEMBER/); + assert.match(publishWorkflow.jobs.prepare.if, /COLLABORATOR/); + assert.equal(publishWorkflow.jobs.build, undefined); - const build = workflow.jobs.build; - assert.equal(build.permissions.packages, undefined); - const buildCheckout = build.steps.find( - (step) => step.name === 'Checkout PR head', - ); - assert.equal(buildCheckout.with.ref, '${{ needs.prepare.outputs.sha }}'); - assert.equal(buildCheckout.with['persist-credentials'], false); + for (const jobName of ['docker-build-app', 'docker-build-worker']) { + const job = ciWorkflow.jobs[jobName]; + const buildImage = job.steps.find((step) => + step.name.startsWith('Build publishable '), + ); + const uploadImage = job.steps.find((step) => + step.name.startsWith('Upload publishable '), + ); + assert.match(buildImage.if, /pull_request/); + assert.match(buildImage.if, /amd64/); + assert.match(buildImage.with.outputs, /type=docker/); + assert.equal(buildImage.with.push, undefined); + assert.equal(uploadImage.with['retention-days'], 1); + } - const buildImage = build.steps.find((step) => step.name.startsWith('Build ')); - assert.match(buildImage.with.outputs, /type=docker/); - assert.equal(buildImage.with.push, undefined); + const prepareScript = publishWorkflow.jobs.prepare.steps[0].run; + assert.match(prepareScript, /actions\/workflows\/CI\.yml\/runs/); + assert.match(prepareScript, /pr-image-app/); - const publisher = workflow.jobs.publish; + const publisher = publishWorkflow.jobs.publish; assert.equal(publisher.permissions.packages, 'write'); assert.equal( publisher.steps.some( @@ -121,12 +132,13 @@ test('GHCR workflow publishes explicitly requested pull request images safely', assert.match(publishScript, /docker load/); assert.match(publishScript, /current_sha/); assert.match(publishScript, /roomote-app roomote-worker/); + assert.match(publishScript, /BASE_VERSION/); assert.equal( publisher.outputs.mutable_updated, '${{ steps.images.outputs.mutable_updated }}', ); - const commentJob = workflow.jobs.comment; + const commentJob = publishWorkflow.jobs.comment; assert.deepEqual(commentJob.needs, ['prepare', 'publish']); assert.equal(commentJob.permissions['pull-requests'], 'write'); assert.match(commentJob.steps[0].run, /roomote-app/); From 3219d854a08d76baf84d25dccf85fb0b6ba9eb66 Mon Sep 17 00:00:00 2001 From: Tom <20028678+tomny-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:41:17 +0000 Subject: [PATCH 3/6] fix: keep PR image tags immutable across base updates --- .github/workflows/CI.yml | 2 +- .github/workflows/publish-pr-images.yml | 23 ++++++++++----------- CONTRIBUTING.md | 5 +++-- scripts/release/__tests__/workflow.test.mjs | 2 ++ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2fe01538b..9744b9ac9 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -50,7 +50,7 @@ jobs: if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then base="${{ github.event.pull_request.base.sha }}" head="${{ github.event.pull_request.head.sha }}" - echo "pr_image_version=pr-${{ github.event.pull_request.number }}-${head:0:8}" >> "$GITHUB_OUTPUT" + echo "pr_image_version=pr-${{ github.event.pull_request.number }}-${head:0:8}-${base:0:8}" >> "$GITHUB_OUTPUT" changed_files="$(git diff --name-only "$base...$head")" else before="${{ github.event.before }}" diff --git a/.github/workflows/publish-pr-images.yml b/.github/workflows/publish-pr-images.yml index 1573b8137..b2cee6ac4 100644 --- a/.github/workflows/publish-pr-images.yml +++ b/.github/workflows/publish-pr-images.yml @@ -28,6 +28,7 @@ jobs: run_id: ${{ steps.pr.outputs.run_id }} worker_artifact: ${{ steps.pr.outputs.worker_artifact }} base_version: ${{ steps.pr.outputs.base_version }} + base_sha: ${{ steps.pr.outputs.base_sha }} steps: - name: Resolve PR head id: pr @@ -69,16 +70,10 @@ jobs: --argjson number "$PR_NUMBER" \ --arg sha "$sha" \ --arg base_sha "$base_sha" \ - '[.[].workflow_runs[] | select(any(.pull_requests[]?; .number == $number and .head.sha == $sha and .base.sha == $base_sha))] | sort_by(.run_number) | reverse | .[0] // empty' \ + '[.[].workflow_runs[] | select(.status == "completed" and .conclusion == "success") | select(any(.pull_requests[]?; .number == $number and .head.sha == $sha and .base.sha == $base_sha))] | sort_by(.run_number) | reverse | .[0] // empty' \ <<<"$runs")" [ -n "$run" ] || { - echo "No CI run found for PR #${PR_NUMBER} at ${sha}." >&2 - exit 1 - } - status="$(jq -r '.status' <<<"$run")" - conclusion="$(jq -r '.conclusion // ""' <<<"$run")" - [ "$status" = 'completed' ] && [ "$conclusion" = 'success' ] || { - echo "Latest CI run for ${sha} is ${status}/${conclusion:-pending}; wait for CI to pass." >&2 + echo "No successful CI run found for PR #${PR_NUMBER} at ${sha} against ${base_sha}." >&2 exit 1 } run_id="$(jq -r '.id' <<<"$run")" @@ -95,10 +90,11 @@ jobs: owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" echo "owner=$owner" >> "$GITHUB_OUTPUT" echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "version=pr-${PR_NUMBER}-${sha:0:8}" >> "$GITHUB_OUTPUT" + echo "version=pr-${PR_NUMBER}-${sha:0:8}-${base_sha:0:8}" >> "$GITHUB_OUTPUT" echo "run_id=$run_id" >> "$GITHUB_OUTPUT" echo "worker_artifact=$worker_artifact" >> "$GITHUB_OUTPUT" echo "base_version=${base_ref}-${base_sha:0:8}" >> "$GITHUB_OUTPUT" + echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT" publish: name: Publish PR images @@ -138,6 +134,7 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} WORKER_ARTIFACT: ${{ needs.prepare.outputs.worker_artifact }} BASE_VERSION: ${{ needs.prepare.outputs.base_version }} + BASE_SHA: ${{ needs.prepare.outputs.base_sha }} shell: bash run: | set -euo pipefail @@ -161,9 +158,11 @@ jobs: docker push "$immutable_ref" done - current_sha="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha')" - if [ "$current_sha" != "$SHA" ]; then - echo "PR advanced to $current_sha; leaving movable tags unchanged." + current_pr="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" + current_sha="$(jq -r '.head.sha' <<<"$current_pr")" + current_base_sha="$(jq -r '.base.sha' <<<"$current_pr")" + if [ "$current_sha" != "$SHA" ] || [ "$current_base_sha" != "$BASE_SHA" ]; then + echo "PR head or base advanced; leaving movable tags unchanged." exit 0 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2af335d7..212d63f71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,8 +52,9 @@ See [`.changeset/README.md`](.changeset/README.md). A repository owner, member, or collaborator can comment `/publish-images` on an open pull request to publish preview builds of `roomote-app` and -`roomote-worker`. The workflow replies with immutable `pr--` -image references and updates the movable `pr-` tags. This works for +`roomote-worker`. The workflow replies with immutable +`pr---` image references and updates the movable +`pr-` tags. This works for fork pull requests: the regular unprivileged CI build exports short-lived image artifacts, while a separate publisher job receives only those completed artifacts and the package-write token. PR preview images currently target diff --git a/scripts/release/__tests__/workflow.test.mjs b/scripts/release/__tests__/workflow.test.mjs index 717c6e9f8..8a7ee130d 100644 --- a/scripts/release/__tests__/workflow.test.mjs +++ b/scripts/release/__tests__/workflow.test.mjs @@ -114,6 +114,7 @@ test('GHCR workflow publishes explicitly requested pull request images safely', const prepareScript = publishWorkflow.jobs.prepare.steps[0].run; assert.match(prepareScript, /actions\/workflows\/CI\.yml\/runs/); + assert.match(prepareScript, /conclusion == "success"/); assert.match(prepareScript, /pr-image-app/); const publisher = publishWorkflow.jobs.publish; @@ -131,6 +132,7 @@ test('GHCR workflow publishes explicitly requested pull request images safely', ).run; assert.match(publishScript, /docker load/); assert.match(publishScript, /current_sha/); + assert.match(publishScript, /current_base_sha/); assert.match(publishScript, /roomote-app roomote-worker/); assert.match(publishScript, /BASE_VERSION/); assert.equal( From f9a872ac9dc4d80683c639301633c22a82617285 Mon Sep 17 00:00:00 2001 From: Tom <20028678+tomny-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:44:03 +0000 Subject: [PATCH 4/6] fix: verify reusable PR image artifacts --- .github/workflows/CI.yml | 20 +++++++++++++++++++- .github/workflows/publish-pr-images.yml | 11 ++++++++++- scripts/release/__tests__/workflow.test.mjs | 2 ++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 9744b9ac9..5e8d274f5 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -50,7 +50,7 @@ jobs: if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then base="${{ github.event.pull_request.base.sha }}" head="${{ github.event.pull_request.head.sha }}" - echo "pr_image_version=pr-${{ github.event.pull_request.number }}-${head:0:8}-${base:0:8}" >> "$GITHUB_OUTPUT" + echo "pr_image_version=pr-${{ github.event.pull_request.number }}-${head}-${base}" >> "$GITHUB_OUTPUT" changed_files="$(git diff --name-only "$base...$head")" else before="${{ github.event.before }}" @@ -106,6 +106,24 @@ jobs: echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT" echo "worker_image_affected=$worker_image_affected" >> "$GITHUB_OUTPUT" + - name: Write PR image metadata + if: ${{ github.event_name == 'pull_request' && steps.changed-paths.outputs.docs_only != 'true' }} + env: + WORKER_IMAGE_AFFECTED: ${{ steps.changed-paths.outputs.worker_image_affected }} + shell: bash + run: | + jq -n \ + --arg worker_image_affected "$WORKER_IMAGE_AFFECTED" \ + '{workerImageAffected: ($worker_image_affected == "true")}' \ + > /tmp/pr-image-metadata.json + - name: Upload PR image metadata + if: ${{ github.event_name == 'pull_request' && steps.changed-paths.outputs.docs_only != 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pr-image-metadata + path: /tmp/pr-image-metadata.json + if-no-files-found: error + retention-days: 1 lint: name: Lint diff --git a/.github/workflows/publish-pr-images.yml b/.github/workflows/publish-pr-images.yml index b2cee6ac4..3a67f40c9 100644 --- a/.github/workflows/publish-pr-images.yml +++ b/.github/workflows/publish-pr-images.yml @@ -82,6 +82,10 @@ jobs: echo "CI run ${run_id} has no publishable app image artifact." >&2 exit 1 } + jq -e '.artifacts[] | select(.name == "pr-image-metadata" and .expired == false)' <<<"$artifacts" >/dev/null || { + echo "CI run ${run_id} has no PR image metadata artifact." >&2 + exit 1 + } worker_artifact=false if jq -e '.artifacts[] | select(.name == "pr-image-worker" and .expired == false)' <<<"$artifacts" >/dev/null; then worker_artifact=true @@ -90,7 +94,7 @@ jobs: owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" echo "owner=$owner" >> "$GITHUB_OUTPUT" echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "version=pr-${PR_NUMBER}-${sha:0:8}-${base_sha:0:8}" >> "$GITHUB_OUTPUT" + echo "version=pr-${PR_NUMBER}-${sha}-${base_sha}" >> "$GITHUB_OUTPUT" echo "run_id=$run_id" >> "$GITHUB_OUTPUT" echo "worker_artifact=$worker_artifact" >> "$GITHUB_OUTPUT" echo "base_version=${base_ref}-${base_sha:0:8}" >> "$GITHUB_OUTPUT" @@ -142,9 +146,14 @@ jobs: echo 'mutable_updated=false' >> "$GITHUB_OUTPUT" docker load --input '/tmp/images/roomote-app.tar' + worker_image_affected="$(jq -r '.workerImageAffected' /tmp/images/pr-image-metadata.json)" if [ "$WORKER_ARTIFACT" = 'true' ]; then docker load --input '/tmp/images/roomote-worker.tar' else + [ "$worker_image_affected" = 'false' ] || { + echo 'CI marked the worker image as affected but produced no worker artifact.' >&2 + exit 1 + } docker pull "ghcr.io/${OWNER}/roomote-worker:${BASE_VERSION}" docker tag \ "ghcr.io/${OWNER}/roomote-worker:${BASE_VERSION}" \ diff --git a/scripts/release/__tests__/workflow.test.mjs b/scripts/release/__tests__/workflow.test.mjs index 8a7ee130d..c1e97a801 100644 --- a/scripts/release/__tests__/workflow.test.mjs +++ b/scripts/release/__tests__/workflow.test.mjs @@ -116,6 +116,7 @@ test('GHCR workflow publishes explicitly requested pull request images safely', assert.match(prepareScript, /actions\/workflows\/CI\.yml\/runs/); assert.match(prepareScript, /conclusion == "success"/); assert.match(prepareScript, /pr-image-app/); + assert.match(prepareScript, /pr-image-metadata/); const publisher = publishWorkflow.jobs.publish; assert.equal(publisher.permissions.packages, 'write'); @@ -135,6 +136,7 @@ test('GHCR workflow publishes explicitly requested pull request images safely', assert.match(publishScript, /current_base_sha/); assert.match(publishScript, /roomote-app roomote-worker/); assert.match(publishScript, /BASE_VERSION/); + assert.match(publishScript, /workerImageAffected/); assert.equal( publisher.outputs.mutable_updated, '${{ steps.images.outputs.mutable_updated }}', From 498715bc88cbe16a868dda00dbc32f7ad937755e Mon Sep 17 00:00:00 2001 From: Tom <20028678+tomny-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:47:29 +0000 Subject: [PATCH 5/6] fix: rebuild expired PR image artifacts --- .github/workflows/publish-pr-images.yml | 97 ++++++++++++++++++--- CONTRIBUTING.md | 4 +- scripts/release/__tests__/workflow.test.mjs | 6 +- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-pr-images.yml b/.github/workflows/publish-pr-images.yml index 3a67f40c9..b9d703bf0 100644 --- a/.github/workflows/publish-pr-images.yml +++ b/.github/workflows/publish-pr-images.yml @@ -29,6 +29,7 @@ jobs: worker_artifact: ${{ steps.pr.outputs.worker_artifact }} base_version: ${{ steps.pr.outputs.base_version }} base_sha: ${{ steps.pr.outputs.base_sha }} + reuse_artifacts: ${{ steps.pr.outputs.reuse_artifacts }} steps: - name: Resolve PR head id: pr @@ -78,16 +79,16 @@ jobs: } run_id="$(jq -r '.id' <<<"$run")" artifacts="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100")" - jq -e '.artifacts[] | select(.name == "pr-image-app" and .expired == false)' <<<"$artifacts" >/dev/null || { - echo "CI run ${run_id} has no publishable app image artifact." >&2 - exit 1 - } - jq -e '.artifacts[] | select(.name == "pr-image-metadata" and .expired == false)' <<<"$artifacts" >/dev/null || { - echo "CI run ${run_id} has no PR image metadata artifact." >&2 - exit 1 - } + reuse_artifacts=false worker_artifact=false - if jq -e '.artifacts[] | select(.name == "pr-image-worker" and .expired == false)' <<<"$artifacts" >/dev/null; then + if jq -e '.artifacts[] | select(.name == "pr-image-app" and .expired == false)' <<<"$artifacts" >/dev/null \ + && jq -e '.artifacts[] | select(.name == "pr-image-metadata" and .expired == false)' <<<"$artifacts" >/dev/null; then + reuse_artifacts=true + if jq -e '.artifacts[] | select(.name == "pr-image-worker" and .expired == false)' <<<"$artifacts" >/dev/null; then + worker_artifact=true + fi + else + # Keep old PRs usable without duplicating fresh CI builds. worker_artifact=true fi @@ -99,10 +100,69 @@ jobs: echo "worker_artifact=$worker_artifact" >> "$GITHUB_OUTPUT" echo "base_version=${base_ref}-${base_sha:0:8}" >> "$GITHUB_OUTPUT" echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT" + echo "reuse_artifacts=$reuse_artifacts" >> "$GITHUB_OUTPUT" + + build: + name: Rebuild ${{ matrix.app }} image + needs: prepare + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - app: app + dockerfile: .docker/app/Dockerfile + target: runtime-app + image: roomote-app + - app: worker + dockerfile: apps/worker/Dockerfile + target: runtime + image: roomote-worker + steps: + - name: Checkout PR merge + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: refs/pull/${{ github.event.issue.number }}/merge + persist-credentials: false + + - name: Read product version + id: product + shell: bash + run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" + + - name: Set up Docker builder + uses: useblacksmith/setup-docker-builder@47a5d0102cc44712a17a633c2599f755008cc40e # v1 + + - name: Build ${{ matrix.app }} image + uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 + with: + context: . + file: ${{ matrix.dockerfile }} + target: ${{ matrix.target }} + platforms: linux/amd64 + tags: local/${{ matrix.image }}:${{ needs.prepare.outputs.version }} + outputs: type=docker,dest=/tmp/${{ matrix.image }}.tar + build-args: | + R_APP_ENV=preview + RELEASE_VERSION=${{ needs.prepare.outputs.version }} + RELEASE_PRODUCT_VERSION=${{ steps.product.outputs.version }} + + - name: Upload image artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pr-image-${{ matrix.app }} + path: /tmp/${{ matrix.image }}.tar + compression-level: 0 + if-no-files-found: error + retention-days: 1 publish: name: Publish PR images - needs: prepare + needs: [prepare, build] + if: ${{ always() && needs.prepare.result == 'success' && (needs.build.result == 'success' || needs.build.result == 'skipped') }} runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: actions: read @@ -112,7 +172,8 @@ jobs: outputs: mutable_updated: ${{ steps.images.outputs.mutable_updated }} steps: - - name: Download image artifacts + - name: Download CI image artifacts + if: ${{ needs.prepare.outputs.reuse_artifacts == 'true' }} uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: pr-image-* @@ -121,6 +182,14 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ needs.prepare.outputs.run_id }} + - name: Download rebuilt image artifacts + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: pr-image-* + path: /tmp/images + merge-multiple: true + - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: @@ -139,6 +208,7 @@ jobs: WORKER_ARTIFACT: ${{ needs.prepare.outputs.worker_artifact }} BASE_VERSION: ${{ needs.prepare.outputs.base_version }} BASE_SHA: ${{ needs.prepare.outputs.base_sha }} + REUSE_ARTIFACTS: ${{ needs.prepare.outputs.reuse_artifacts }} shell: bash run: | set -euo pipefail @@ -146,7 +216,10 @@ jobs: echo 'mutable_updated=false' >> "$GITHUB_OUTPUT" docker load --input '/tmp/images/roomote-app.tar' - worker_image_affected="$(jq -r '.workerImageAffected' /tmp/images/pr-image-metadata.json)" + worker_image_affected=true + if [ "$REUSE_ARTIFACTS" = 'true' ]; then + worker_image_affected="$(jq -r '.workerImageAffected' /tmp/images/pr-image-metadata.json)" + fi if [ "$WORKER_ARTIFACT" = 'true' ]; then docker load --input '/tmp/images/roomote-worker.tar' else diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 212d63f71..5c3b57060 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,9 @@ an open pull request to publish preview builds of `roomote-app` and fork pull requests: the regular unprivileged CI build exports short-lived image artifacts, while a separate publisher job receives only those completed artifacts and the package-write token. PR preview images currently target -`linux/amd64` and require a successful CI run for the current head commit. +`linux/amd64` and require a successful CI run for the current head commit. The +publisher reuses fresh CI artifacts and rebuilds only when those short-lived +artifacts are unavailable. ### How a release ships diff --git a/scripts/release/__tests__/workflow.test.mjs b/scripts/release/__tests__/workflow.test.mjs index c1e97a801..a80b6349c 100644 --- a/scripts/release/__tests__/workflow.test.mjs +++ b/scripts/release/__tests__/workflow.test.mjs @@ -95,7 +95,9 @@ test('GHCR workflow publishes explicitly requested pull request images safely', assert.match(publishWorkflow.jobs.prepare.if, /OWNER/); assert.match(publishWorkflow.jobs.prepare.if, /MEMBER/); assert.match(publishWorkflow.jobs.prepare.if, /COLLABORATOR/); - assert.equal(publishWorkflow.jobs.build, undefined); + const fallbackBuild = publishWorkflow.jobs.build; + assert.match(fallbackBuild.if, /reuse_artifacts != 'true'/); + assert.equal(fallbackBuild.permissions.packages, undefined); for (const jobName of ['docker-build-app', 'docker-build-worker']) { const job = ciWorkflow.jobs[jobName]; @@ -117,6 +119,7 @@ test('GHCR workflow publishes explicitly requested pull request images safely', assert.match(prepareScript, /conclusion == "success"/); assert.match(prepareScript, /pr-image-app/); assert.match(prepareScript, /pr-image-metadata/); + assert.match(prepareScript, /reuse_artifacts=false/); const publisher = publishWorkflow.jobs.publish; assert.equal(publisher.permissions.packages, 'write'); @@ -137,6 +140,7 @@ test('GHCR workflow publishes explicitly requested pull request images safely', assert.match(publishScript, /roomote-app roomote-worker/); assert.match(publishScript, /BASE_VERSION/); assert.match(publishScript, /workerImageAffected/); + assert.match(publisher.if, /needs\.build\.result == 'skipped'/); assert.equal( publisher.outputs.mutable_updated, '${{ steps.images.outputs.mutable_updated }}', From aeed9241322758d64f02a9e7a85b6676497d06ca Mon Sep 17 00:00:00 2001 From: Tom <20028678+tomny-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:49:40 +0000 Subject: [PATCH 6/6] fix: pin fallback PR merge builds --- .github/workflows/publish-pr-images.yml | 24 +++++++++++++++++++++ scripts/release/__tests__/workflow.test.mjs | 4 ++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/publish-pr-images.yml b/.github/workflows/publish-pr-images.yml index b9d703bf0..a045c0f72 100644 --- a/.github/workflows/publish-pr-images.yml +++ b/.github/workflows/publish-pr-images.yml @@ -30,6 +30,7 @@ jobs: base_version: ${{ steps.pr.outputs.base_version }} base_sha: ${{ steps.pr.outputs.base_sha }} reuse_artifacts: ${{ steps.pr.outputs.reuse_artifacts }} + merge_sha: ${{ steps.pr.outputs.merge_sha }} steps: - name: Resolve PR head id: pr @@ -44,6 +45,7 @@ jobs: state="$(jq -r '.state' <<<"$pr")" base_ref="$(jq -r '.base.ref' <<<"$pr")" base_sha="$(jq -r '.base.sha' <<<"$pr")" + merge_sha="$(jq -r '.merge_commit_sha' <<<"$pr")" [ "$state" = 'open' ] || { echo "Pull request #${PR_NUMBER} is not open." >&2 exit 1 @@ -58,6 +60,16 @@ jobs: echo "Invalid pull request head SHA length: $sha" >&2 exit 1 } + case "$merge_sha" in + '' | null | *[!0-9a-f]*) + echo "Invalid pull request merge SHA: $merge_sha" >&2 + exit 1 + ;; + esac + [ "${#merge_sha}" -eq 40 ] || { + echo "Invalid pull request merge SHA length: $merge_sha" >&2 + exit 1 + } case "$base_ref" in develop | main) ;; *) @@ -101,6 +113,7 @@ jobs: echo "base_version=${base_ref}-${base_sha:0:8}" >> "$GITHUB_OUTPUT" echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT" echo "reuse_artifacts=$reuse_artifacts" >> "$GITHUB_OUTPUT" + echo "merge_sha=$merge_sha" >> "$GITHUB_OUTPUT" build: name: Rebuild ${{ matrix.app }} image @@ -128,6 +141,17 @@ jobs: ref: refs/pull/${{ github.event.issue.number }}/merge persist-credentials: false + - name: Verify PR merge + env: + EXPECTED_MERGE_SHA: ${{ needs.prepare.outputs.merge_sha }} + shell: bash + run: | + actual_merge_sha="$(git rev-parse HEAD)" + [ "$actual_merge_sha" = "$EXPECTED_MERGE_SHA" ] || { + echo "PR merge advanced from $EXPECTED_MERGE_SHA to $actual_merge_sha; run /publish-images again." >&2 + exit 1 + } + - name: Read product version id: product shell: bash diff --git a/scripts/release/__tests__/workflow.test.mjs b/scripts/release/__tests__/workflow.test.mjs index a80b6349c..18a64c828 100644 --- a/scripts/release/__tests__/workflow.test.mjs +++ b/scripts/release/__tests__/workflow.test.mjs @@ -98,6 +98,10 @@ test('GHCR workflow publishes explicitly requested pull request images safely', const fallbackBuild = publishWorkflow.jobs.build; assert.match(fallbackBuild.if, /reuse_artifacts != 'true'/); assert.equal(fallbackBuild.permissions.packages, undefined); + assert.match( + fallbackBuild.steps.find((step) => step.name === 'Verify PR merge').run, + /EXPECTED_MERGE_SHA/, + ); for (const jobName of ['docker-build-app', 'docker-build-worker']) { const job = ciWorkflow.jobs[jobName];