diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index abfb42436..5e8d274f5 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}-${base}" >> "$GITHUB_OUTPUT" changed_files="$(git diff --name-only "$base...$head")" else before="${{ github.event.before }}" @@ -102,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 @@ -306,6 +328,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 +337,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 +398,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 +407,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 new file mode 100644 index 000000000..a045c0f72 --- /dev/null +++ b/.github/workflows/publish-pr-images.yml @@ -0,0 +1,323 @@ +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: + 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 }} + 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 + 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")" + 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 + } + 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 + } + 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) ;; + *) + 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(.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 successful CI run found for PR #${PR_NUMBER} at ${sha} against ${base_sha}." >&2 + exit 1 + } + run_id="$(jq -r '.id' <<<"$run")" + artifacts="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100")" + reuse_artifacts=false + worker_artifact=false + 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 + + 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}-${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" + 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 + 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: 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 + 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, build] + if: ${{ always() && needs.prepare.result == 'success' && (needs.build.result == 'success' || needs.build.result == 'skipped') }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + actions: read + contents: read + packages: write + pull-requests: read + outputs: + mutable_updated: ${{ steps.images.outputs.mutable_updated }} + steps: + - name: Download CI 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 + 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: + 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 }} + 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 + mutable_version="pr-${PR_NUMBER}" + echo 'mutable_updated=false' >> "$GITHUB_OUTPUT" + + docker load --input '/tmp/images/roomote-app.tar' + 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 + [ "$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}" \ + "local/roomote-worker:${VERSION}" + fi + + for image in roomote-app roomote-worker; do + local_ref="local/${image}:${VERSION}" + immutable_ref="ghcr.io/${OWNER}/${image}:${VERSION}" + docker tag "$local_ref" "$immutable_ref" + docker push "$immutable_ref" + done + + 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 + + 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: 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. The +publisher reuses fresh CI artifacts and rebuilds only when those short-lived +artifacts are unavailable. + ### 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..18a64c828 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,84 @@ 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 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(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/); + 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]; + 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 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/); + assert.match(prepareScript, /pr-image-metadata/); + assert.match(prepareScript, /reuse_artifacts=false/); + + const publisher = publishWorkflow.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, /current_base_sha/); + 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 }}', + ); + + 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/); + assert.match(commentJob.steps[0].run, /roomote-worker/); + assert.match(commentJob.steps[0].run, /Movable references/); + assert.match(commentJob.steps[0].run, /MUTABLE_UPDATED/); +});