diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 19e9f37935..3ac3871887 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -520,6 +520,7 @@ jobs: io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }} - name: Build and push (cloud) + id: build-cloud uses: docker/build-push-action@v7 with: context: . @@ -557,18 +558,16 @@ jobs: - name: Verify the pushed image resolves the declared Sentry version env: - IMAGE_TAGS: ${{ steps.meta-cloud.outputs.tags }} + IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} run: | set -euo pipefail - image="$(printf '%s\n' "$IMAGE_TAGS" | head -n 1)" - test -n "$image" expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")" test -n "$expected" installed="$(docker run --rm --pull always \ -v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \ - --entrypoint node "$image" /app/server/.ci-sentry-probe.mjs)" + --entrypoint node "$IMAGE" /app/server/.ci-sentry-probe.mjs)" echo "Declared optional peer version: $expected" echo "Installed in the pushed image: $installed" @@ -578,6 +577,21 @@ jobs: fi echo "The pushed image resolves the declared @sentry/node version." + # Cloud's commit resolver and preview-artifact planner use the full SHA. + # Publish that address only after checking this build's exact digest. + # Retagging reuses the registry manifest and does not rebuild the image. + - name: Publish verified full-SHA cloud tag + env: + IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} + FULL_SHA_TAG: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}-cloud + run: | + set -euo pipefail + revision="$(docker image inspect "$IMAGE" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" + platform="$(docker image inspect "$IMAGE" --format '{{ .Os }}/{{ .Architecture }}')" + test "$revision" = "$GITHUB_SHA" + test "$platform" = linux/amd64 + docker buildx imagetools create --prefer-index=false --tag "$FULL_SHA_TAG" "$IMAGE" + # Moves the mutable `:canary` / `:canary-cloud` channel tags. Kept OUT # of the build jobs and serialized in its own lane, and — the load- # bearing property — CONVERGENT rather than self-interested: a diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 01278a8343..93ffe55554 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -24,6 +24,19 @@ docker build -t paperclip-local \ --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) . ``` +## Cloud image addresses + +The Docker workflow publishes the managed deployment image for Linux AMD64. +After the pushed image passes its Sentry check, the workflow verifies its +commit label and platform and adds `ghcr.io/paperclipai/paperclip:sha--cloud`. +This address lets commit-based deployment tooling reuse the normal build. +Existing short-SHA and release tags remain available. + +The full-SHA tag identifies the source commit. It does not certify that source +tests passed or that a compatible database migrator is available. Deployment +tooling must still check those prerequisites and pin the resolved image digest; +a rebuild of the same source can update the tag's digest. + ## One-liner (build + run) ```sh diff --git a/scripts/preview-artifacts.test.mjs b/scripts/preview-artifacts.test.mjs index 746f246cf9..ad42cbd6bc 100644 --- a/scripts/preview-artifacts.test.mjs +++ b/scripts/preview-artifacts.test.mjs @@ -4,6 +4,7 @@ import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { gzipSync } from "node:zlib"; +import { spawnSync } from "node:child_process"; import { previewManifest, assertMetadata, validateRequest, versionFor, tarManifest, packageExists, imageExists, publishPreview, publishImage } from "./preview-artifacts.mjs"; const sha = "a".repeat(40); @@ -126,3 +127,49 @@ test("commits sharing a short prefix use separate full-SHA image addresses", asy await imageExists(other, fetchImpl); assert.deepEqual(urls.filter((url) => url.includes("/manifests/")), [sha, other].map((commit) => `https://ghcr.io/v2/paperclipai/paperclip/manifests/sha-${commit}-cloud`)); }); + +test("normal cloud builds publish the checked digest only when source and platform match", () => { + const workflow = readFileSync(new URL("../.github/workflows/docker.yml", import.meta.url), "utf8"); + const cloud = workflow.split(" build-and-push-cloud:")[1].split(" promote_canary_channel:")[0]; + const verify = cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"); + const publish = cloud.indexOf(" - name: Publish verified full-SHA cloud tag"); + assert.ok(verify >= 0 && publish > verify); + const verification = cloud.slice(verify, publish); + assert.match(verification, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/); + assert.doesNotMatch(verification, /continue-on-error:|if: always\(/); + const step = cloud.slice(publish).split(/\n(?: #| - name:)/)[0]; + assert.doesNotMatch(step, /continue-on-error:|if:/); + assert.match(step, /FULL_SHA_TAG: ghcr.io\/\$\{\{ github.repository \}\}:sha-\$\{\{ github.sha \}\}-cloud/); + const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n"); + const dir = mkdtempSync(path.join(tmpdir(), "cloud-tag-test-")); + const image = `ghcr.io/paperclipai/paperclip@sha256:${"b".repeat(64)}`; + const tag = `ghcr.io/paperclipai/paperclip:sha-${sha}-cloud`; + try { + writeFileSync(path.join(dir, "docker"), `#!/bin/sh +case "$1 $2" in + 'image inspect') + case "$5" in + *revision*) printf '%s\\n' "$TEST_REVISION" ;; + *) printf '%s\\n' "$TEST_PLATFORM" ;; + esac ;; + 'buildx imagetools') printf '%s\\n' "$@" > "$TEST_CALLS" ;; + *) exit 99 ;; +esac +`, { mode: 0o755 }); + for (const [revision, platform, succeeds] of [[sha, "linux/amd64", true], ["c".repeat(40), "linux/amd64", false], [sha, "linux/arm64", false]]) { + const calls = path.join(dir, "calls"); + rmSync(calls, { force: true }); + const result = spawnSync("bash", ["-c", script], { encoding: "utf8", env: { + ...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`, GITHUB_SHA: sha, + IMAGE: image, FULL_SHA_TAG: tag, TEST_REVISION: revision, TEST_PLATFORM: platform, TEST_CALLS: calls, + } }); + if (succeeds) { + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readFileSync(calls, "utf8").trim().split("\n"), ["buildx", "imagetools", "create", "--prefer-index=false", "--tag", tag, image]); + } else { + assert.notEqual(result.status, 0); + assert.throws(() => readFileSync(calls), { code: "ENOENT" }); + } + } + } finally { rmSync(dir, { recursive: true, force: true }); } +});