Skip to content

Commit ea43eee

Browse files
authored
devops(docker): move docker publishing to Azure Pipelines (#1756)
## Summary - Replace the GitHub Actions docker publish job with `.azure-pipelines/publish-docker.yml`, mirroring microsoft/playwright's pipeline: `v*` tag trigger, stable/canary channel, ACR cache prefix for the base image, arm64-first build order and arm64 build retries under QEMU. - Add `utils/docker/build.sh` and `utils/docker/publish_docker.sh`; per-arch images are pushed with `-amd64`/`-arm64` suffixes and joined into `v<version>` and `latest` manifests. - Forward the authenticated `.npmrc` to `npm ci` inside the image as a BuildKit secret.
1 parent e73d72e commit ea43eee

6 files changed

Lines changed: 298 additions & 47 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Publishes the @playwright/mcp docker image to ACR (mirrored to mcr.microsoft.com/playwright/mcp).
2+
# Trigger: any `v*` release tag (e.g. v0.0.81).
3+
# Can also be queued manually from the ADO UI, e.g. for a canary build from main.
4+
trigger:
5+
tags:
6+
include:
7+
- v*
8+
exclude:
9+
- v*-*
10+
11+
pr: none
12+
13+
parameters:
14+
- name: releaseChannel
15+
displayName: "IMPORTANT: set this to 'canary' when triggering manually"
16+
type: string
17+
default: stable
18+
values:
19+
- stable
20+
- canary
21+
22+
resources:
23+
repositories:
24+
- repository: 1esPipelines
25+
type: git
26+
name: 1ESPipelineTemplates/1ESPipelineTemplates
27+
ref: refs/tags/release
28+
29+
extends:
30+
template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines
31+
parameters:
32+
pool:
33+
name: DevDivPlaywrightAzurePipelinesUbuntu2204
34+
os: linux
35+
sdl:
36+
sourceAnalysisPool:
37+
# SDL tools require windows, see https://aka.ms/AAo6v8e
38+
name: DevDivPlaywrightAzurePipelinesWindows2022
39+
os: windows
40+
stages:
41+
- stage: Publish
42+
jobs:
43+
- job: PublishDocker
44+
displayName: "Publish Docker image to ACR"
45+
# The arm64 image is cross-built under QEMU emulation, which is slow.
46+
timeoutInMinutes: 360
47+
steps:
48+
- checkout: self
49+
displayName: "Checkout code"
50+
51+
- task: UseNode@1
52+
inputs:
53+
version: '24.x'
54+
displayName: "Install Node.js"
55+
56+
- task: Bash@3
57+
displayName: "Check the ref matches the release channel"
58+
inputs:
59+
targetType: "inline"
60+
script: |
61+
set -e
62+
VERSION=$(node -p "require('./package.json').version")
63+
if [[ "$RELEASE_CHANNEL" == "stable" && "$BUILD_SOURCE_BRANCH" != "refs/tags/v$VERSION" ]]; then
64+
echo "ERROR: stable images can only be published from the 'v$VERSION' tag."
65+
echo "Unexpected ref: $BUILD_SOURCE_BRANCH"
66+
exit 1
67+
fi
68+
env:
69+
BUILD_SOURCE_BRANCH: $(Build.SourceBranch)
70+
RELEASE_CHANNEL: ${{ parameters.releaseChannel }}
71+
72+
# Relocate the Docker data-root to the large /mnt volume: the default disk
73+
# is too small for the browser-bearing image layers of both architectures.
74+
- task: Bash@3
75+
displayName: "Setup docker"
76+
inputs:
77+
targetType: "inline"
78+
script: |
79+
set -x
80+
sudo service docker stop
81+
sudo mkdir -p /etc/docker
82+
echo '{ "data-root": "/mnt/docker" }' | sudo tee /etc/docker/daemon.json
83+
sudo service docker start
84+
85+
# `npm ci` inside the image build reads this .npmrc (passed as a BuildKit
86+
# secret by utils/docker/build.sh) to resolve packages through the DevDiv feed.
87+
- task: Bash@3
88+
displayName: "setup .npmrc"
89+
inputs:
90+
targetType: "inline"
91+
script: |
92+
echo "registry=https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/DevDiv_PublicPackages/npm/registry/" >> .npmrc
93+
94+
- task: npmAuthenticate@0
95+
displayName: "authenticate the private npm registry"
96+
inputs:
97+
workingFile: .npmrc
98+
99+
- task: AzureCLI@2
100+
displayName: "Login to ACR via OIDC"
101+
inputs:
102+
azureSubscription: "Playwright-CDN"
103+
scriptType: "bash"
104+
scriptLocation: "inlineScript"
105+
inlineScript: "az acr login --name playwright"
106+
107+
- task: Bash@3
108+
displayName: "Register QEMU (binfmt) for arm64 cross-build"
109+
inputs:
110+
targetType: "inline"
111+
script: "docker run --rm --privileged ${ACR_CACHE_PREFIX}tonistiigi/binfmt --install arm64"
112+
env:
113+
ACR_CACHE_PREFIX: "playwright.azurecr.io/cached/"
114+
115+
- task: Bash@3
116+
displayName: "Build & publish Docker image"
117+
inputs:
118+
targetType: "inline"
119+
script: "./utils/docker/publish_docker.sh ${{ parameters.releaseChannel }}"
120+
env:
121+
ACR_CACHE_PREFIX: "playwright.azurecr.io/cached/"
122+
NPMRC_SECRET: "$(Build.SourcesDirectory)/.npmrc"

.github/workflows/publish.yml

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -105,49 +105,3 @@ jobs:
105105

106106
- name: Publish to MCP Registry
107107
run: ./mcp-publisher publish
108-
109-
publish-mcp-release-docker:
110-
if: github.event_name == 'release'
111-
runs-on: ubuntu-latest
112-
permissions:
113-
contents: read
114-
id-token: write # Needed for OIDC login to Azure
115-
environment: allow-publishing-docker-to-acr
116-
steps:
117-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
118-
- name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner)
119-
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
120-
- name: Set up Docker Buildx # Needed for multi-platform builds
121-
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
122-
- name: Azure Login via OIDC
123-
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
124-
with:
125-
client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }}
126-
tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }}
127-
subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }}
128-
- name: Login to ACR
129-
run: az acr login --name playwright
130-
- name: Build and push Docker image
131-
id: build-push
132-
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
133-
with:
134-
file: ./Dockerfile
135-
platforms: linux/amd64,linux/arm64
136-
push: true
137-
tags: |
138-
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
139-
playwright.azurecr.io/public/playwright/mcp:latest
140-
- uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1
141-
- name: Set oras tags
142-
run: |
143-
attach_eol_manifest() {
144-
local image="$1"
145-
local today=$(date -u +'%Y-%m-%d')
146-
# oras is re-using Docker credentials, so we don't need to login.
147-
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
148-
oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
149-
}
150-
# for each tag, attach the eol manifest
151-
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
152-
attach_eol_manifest $tag
153-
done

Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
2+
# Registry prefix for the base image, e.g. 'playwright.azurecr.io/cached/' in the publish pipeline.
3+
ARG ACR_CACHE_PREFIX
24

35
# ------------------------------
46
# Base
57
# ------------------------------
68
# Base stage: Contains only the minimal dependencies required for runtime
79
# (node_modules and Playwright system dependencies)
8-
FROM node:22-bookworm-slim AS base
10+
FROM ${ACR_CACHE_PREFIX}node:22-bookworm-slim AS base
911

1012
ARG PLAYWRIGHT_BROWSERS_PATH
1113
ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH}
@@ -16,6 +18,7 @@ WORKDIR /app
1618
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
1719
--mount=type=bind,source=package.json,target=package.json \
1820
--mount=type=bind,source=package-lock.json,target=package-lock.json \
21+
--mount=type=secret,id=npmrc,target=/root/.npmrc,required=false \
1922
npm ci --omit=dev && \
2023
# Install system dependencies for playwright
2124
npx -y playwright-core install-deps chromium
@@ -28,6 +31,7 @@ FROM base AS builder
2831
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
2932
--mount=type=bind,source=package.json,target=package.json \
3033
--mount=type=bind,source=package-lock.json,target=package-lock.json \
34+
--mount=type=secret,id=npmrc,target=/root/.npmrc,required=false \
3135
npm ci
3236

3337
# Copy the rest of the app

utils/docker/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
oras/

utils/docker/build.sh

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env bash
2+
set -e
3+
set +x
4+
5+
if [[ ($1 == '--help') || ($1 == '-h') || ($1 == '') || ($2 == '') ]]; then
6+
echo "usage: $(basename $0) {--arm64,--amd64} playwright-mcp:localbuild"
7+
echo
8+
echo "Build the Playwright MCP docker image and tag it as 'playwright-mcp:localbuild'."
9+
echo "Once the image is built, you can run it with"
10+
echo ""
11+
echo " docker run -i --rm --init playwright-mcp:localbuild"
12+
echo ""
13+
echo "Optional environment variables:"
14+
echo " ACR_CACHE_PREFIX registry prefix for the base image, e.g. 'playwright.azurecr.io/cached/'"
15+
echo " NPMRC_SECRET path to an .npmrc to use for 'npm ci' inside the image (BuildKit secret)"
16+
echo ""
17+
exit 0
18+
fi
19+
20+
trap "cd $(pwd -P)" EXIT
21+
# The Dockerfile lives at the repository root, which is also the build context.
22+
cd "$(dirname "$0")/../.."
23+
24+
PLATFORM=""
25+
if [[ "$1" == "--arm64" ]]; then
26+
PLATFORM="linux/arm64";
27+
elif [[ "$1" == "--amd64" ]]; then
28+
PLATFORM="linux/amd64"
29+
else
30+
echo "ERROR: unknown platform specifier - $1. Only --arm64 or --amd64 is supported"
31+
exit 1
32+
fi
33+
34+
# Let npm inside the image use the same registry as the host. Passed as a BuildKit
35+
# secret, so the (possibly authenticated) .npmrc never lands in an image layer.
36+
SECRET_ARGS=()
37+
if [[ -n "${NPMRC_SECRET:-}" ]]; then
38+
SECRET_ARGS+=(--secret "id=npmrc,src=${NPMRC_SECRET}")
39+
fi
40+
41+
# Keep each arch image a plain single-platform manifest without the unknown/unknown platform entry.
42+
export BUILDX_NO_DEFAULT_ATTESTATIONS=1
43+
44+
# The arm64 image is cross-built under QEMU user-mode emulation, where ldconfig
45+
# segfaults intermittently at startup (tonistiigi/binfmt#298, every binfmt build
46+
# since QEMU 8.1.4). apt's libc-bin trigger runs ldconfig, so a crash fails the
47+
# whole `docker build`. Retry: BuildKit keeps the layers that already succeeded, so
48+
# a retry re-runs only the failed RUN step.
49+
MAX_ATTEMPTS=1
50+
if [[ "${PLATFORM}" == "linux/arm64" ]]; then
51+
MAX_ATTEMPTS=3
52+
fi
53+
54+
for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
55+
if docker build --platform "${PLATFORM}" \
56+
--build-arg ACR_CACHE_PREFIX="${ACR_CACHE_PREFIX:-}" \
57+
"${SECRET_ARGS[@]}" \
58+
-t "$2" -f Dockerfile .; then
59+
exit 0
60+
fi
61+
if (( attempt < MAX_ATTEMPTS )); then
62+
echo "docker build failed (attempt ${attempt}/${MAX_ATTEMPTS}), retrying..." >&2
63+
fi
64+
done
65+
echo "ERROR: docker build failed after ${MAX_ATTEMPTS} attempt(s)" >&2
66+
exit 1

utils/docker/publish_docker.sh

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
set +x
5+
6+
trap "cd $(pwd -P)" EXIT
7+
cd "$(dirname "$0")"
8+
9+
MCR_IMAGE_NAME="playwright/mcp"
10+
11+
RELEASE_CHANNEL="$1"
12+
if [[ "${RELEASE_CHANNEL}" != "stable" && "${RELEASE_CHANNEL}" != "canary" ]]; then
13+
echo "ERROR: unknown release channel - '${RELEASE_CHANNEL}'"
14+
echo "Must be either 'stable' or 'canary'"
15+
exit 1
16+
fi
17+
18+
MCP_VERSION=$(node -p "require('../../package.json').version")
19+
if [[ "${RELEASE_CHANNEL}" == "stable" && ! "${MCP_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
20+
echo "ERROR: cannot publish stable docker with @playwright/mcp version '${MCP_VERSION}'"
21+
exit 1
22+
fi
23+
24+
TAGS=()
25+
if [[ "${RELEASE_CHANNEL}" == "stable" ]]; then
26+
TAGS+=("v${MCP_VERSION}" "latest")
27+
else
28+
TAGS+=("v${MCP_VERSION}-canary-$(date -u +'%Y%m%d%H%M%S')")
29+
echo "== CANARY build: publishing to ${TAGS[0]} tag =="
30+
fi
31+
32+
tag_and_push() {
33+
local source="$1"
34+
local target="$2"
35+
echo "-- tagging: $target"
36+
docker tag $source $target
37+
docker push $target
38+
attach_eol_manifest $target
39+
}
40+
41+
attach_eol_manifest() {
42+
local image="$1"
43+
local today=$(date -u +'%Y-%m-%d')
44+
install_oras_if_needed
45+
# oras is re-using Docker credentials, so we don't need to login.
46+
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
47+
./oras/oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
48+
}
49+
50+
install_oras_if_needed() {
51+
if [[ -x oras/oras ]]; then
52+
return
53+
fi
54+
local version="1.1.0"
55+
local arch="amd64"
56+
if [[ "$(uname -m)" == "aarch64" || "$(uname -m)" == "arm64" ]]; then
57+
arch="arm64"
58+
fi
59+
curl -sLO "https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_linux_${arch}.tar.gz"
60+
mkdir -p oras
61+
tar -zxf oras_${version}_linux_${arch}.tar.gz -C oras
62+
rm oras_${version}_linux_${arch}.tar.gz
63+
}
64+
65+
publish_docker_images_with_arch_suffix() {
66+
local ARCH="$1"
67+
if [[ "$ARCH" != "amd64" && "$ARCH" != "arm64" ]]; then
68+
echo "ERROR: unknown arch - $ARCH. Must be either 'amd64' or 'arm64'"
69+
exit 1
70+
fi
71+
# Prune docker images to avoid platform conflicts
72+
docker system prune -fa
73+
./build.sh "--${ARCH}" playwright-mcp:localbuild
74+
75+
for ((i = 0; i < ${#TAGS[@]}; i++)) do
76+
local TAG="${TAGS[$i]}"
77+
tag_and_push playwright-mcp:localbuild "playwright.azurecr.io/public/${MCR_IMAGE_NAME}:${TAG}-${ARCH}"
78+
done
79+
}
80+
81+
publish_docker_manifest () {
82+
for ((i = 0; i < ${#TAGS[@]}; i++)) do
83+
local TAG="${TAGS[$i]}"
84+
local BASE_IMAGE_TAG="playwright.azurecr.io/public/${MCR_IMAGE_NAME}:${TAG}"
85+
local IMAGE_NAMES=""
86+
if [[ "$1" == "arm64" || "$1" == "amd64" ]]; then
87+
IMAGE_NAMES="${IMAGE_NAMES} ${BASE_IMAGE_TAG}-$1"
88+
fi
89+
if [[ "$2" == "arm64" || "$2" == "amd64" ]]; then
90+
IMAGE_NAMES="${IMAGE_NAMES} ${BASE_IMAGE_TAG}-$2"
91+
fi
92+
docker manifest create "${BASE_IMAGE_TAG}" $IMAGE_NAMES
93+
docker manifest push "${BASE_IMAGE_TAG}"
94+
attach_eol_manifest "${BASE_IMAGE_TAG}"
95+
done
96+
}
97+
98+
# arm64 first: its QEMU-emulated build must run while the host is fresh. Running
99+
# it after the native amd64 build has churned the host triggers a qemu segfault
100+
# in aarch64 ldconfig during libc-bin setup. amd64 is a native build and is
101+
# unaffected by preceding work, so it goes second.
102+
publish_docker_images_with_arch_suffix arm64
103+
publish_docker_images_with_arch_suffix amd64
104+
publish_docker_manifest amd64 arm64

0 commit comments

Comments
 (0)