From c2dc7c11a961ee61fcfe35cd75562da08dc8bfb5 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 5 Feb 2026 12:02:45 +0100 Subject: [PATCH] Split multi-arch Docker build into parallel jobs - Modify Dockerfile to build single architecture based on TARGETARCH instead of cross-compiling both targets in one run - Replace single build-image job with matrix job (amd64, arm64) - Add finalize-image job that creates multi-arch manifests using `docker buildx imagetools create` and signs the final images - Each architecture gets its own build cache This enables parallel builds of each architecture, reducing total build time by running both simultaneously rather than sequentially. --- .github/workflows/build.yaml | 333 +++++++++++++++++++++++++++++------ Dockerfile | 23 +-- docker-bake.hcl | 10 +- 3 files changed, 296 insertions(+), 70 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2a7425221..b5b1c8e5b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -207,25 +207,17 @@ jobs: name: mas-cli-x86_64-linux path: mas-cli-x86_64-linux.tar.gz - build-image: - name: Build and push Docker image + compute-image-meta: + name: Compute Docker image metadata if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow' runs-on: ubuntu-24.04 - outputs: - metadata: ${{ steps.metadata.outputs.result }} - permissions: contents: read - packages: write - id-token: write - needs: - - compute-version - - env: - VERGEN_GIT_DESCRIBE: ${{ needs.compute-version.outputs.describe }} - SOURCE_DATE_EPOCH: ${{ needs.compute-version.outputs.timestamp }} + outputs: + regular-json: ${{ steps.meta.outputs.json }} + debug-json: ${{ steps.meta-debug.outputs.json }} steps: - name: Docker meta @@ -247,6 +239,15 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha + # GitHub's license detection (via Licensee) can only report a single + # SPDX identifier and still emits the legacy `AGPL-3.0` form, which + # `metadata-action` would otherwise propagate as-is. Override it with + # the project's actual dual-license SPDX expression so the image + # advertises both halves of the dual licensing. + labels: | + org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial + annotations: | + org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial - name: Docker meta (debug variant) id: meta-debug @@ -266,10 +267,63 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha + labels: | + org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial + annotations: | + org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial - - name: Setup Cosign - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + # Stage the labels bake files under predictable names (the metadata-action + # writes them under a random temp dir with the same base name) and ship + # them to `build-image` as an artifact. We deliberately only pass through + # `bake-file-labels` (not `bake-file-annotations`): the per-arch images + # then carry the same config labels as today, while annotations are only + # applied at the index level in `finalize-image` — matching `:latest`'s + # shape and sidestepping `metadata-action`'s empty-value annotations + # which would otherwise trip `docker buildx imagetools create`. + - name: Stage bake files + env: + REGULAR_FILE: ${{ steps.meta.outputs.bake-file-labels }} + DEBUG_FILE: ${{ steps.meta-debug.outputs.bake-file-labels }} + run: | + mkdir -p /tmp/bake + cp "$REGULAR_FILE" /tmp/bake/regular.json + cp "$DEBUG_FILE" /tmp/bake/debug.json + - name: Upload bake files + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: bake-files + path: /tmp/bake/ + retention-days: 1 + + build-image: + name: Build Docker image (${{ matrix.arch }}) + if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow' + runs-on: ubuntu-24.04 + + permissions: + contents: read + packages: write + id-token: write + + needs: + - compute-version + - compute-image-meta + + strategy: + fail-fast: false + matrix: + arch: [amd64, arm64] + + env: + VERGEN_GIT_DESCRIBE: ${{ needs.compute-version.outputs.describe }} + SOURCE_DATE_EPOCH: ${{ needs.compute-version.outputs.timestamp }} + # Comma-separated list of registries to push each per-arch image to. + # The oci-push registry is only included on `push` events because the + # Tailscale + Vault login below requires the right OIDC token. + IMAGES: ghcr.io/element-hq/matrix-authentication-service${{ github.event_name == 'push' && ',oci-push.vpn.infra.element.io/matrix-authentication-service' || '' }} + + steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 with: @@ -323,7 +377,13 @@ jobs: username: ${{ steps.import-secrets.outputs.OCI_USERNAME }} password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }} - - name: Build and push + - name: Download bake files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: bake-files + path: /tmp/bake + + - name: Build and push by digest id: bake uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0 env: @@ -337,35 +397,185 @@ jobs: with: files: | ./docker-bake.hcl - cwd://${{ steps.meta.outputs.bake-file }} - cwd://${{ steps.meta-debug.outputs.bake-file }} + cwd:///tmp/bake/regular.json + cwd:///tmp/bake/debug.json set: | - base.output=type=image,push=true - base.cache-from=type=registry,ref=${{ env.BUILDCACHE }}:buildcache - base.cache-to=type=registry,ref=${{ env.BUILDCACHE }}:buildcache,mode=max + *.platform=linux/${{ matrix.arch }} + *.output=type=image,"name=${{ env.IMAGES }}",push-by-digest=true,name-canonical=true,push=true + *.cache-from=type=registry,ref=${{ env.BUILDCACHE }}:buildcache-${{ matrix.arch }} + *.cache-to=type=registry,ref=${{ env.BUILDCACHE }}:buildcache-${{ matrix.arch }},mode=max - - name: Transform bake output - # This transforms the output to an object which looks like this: - # { regular: { digest: "…", tags: ["…", "…"] }, debug: { digest: "…", tags: ["…"] }, … } - # We use github-script rather than shelling out to jq because the bake - # metadata can exceed the shell ARG_MAX limit when expanded. - id: metadata + # We use github-script rather than shelling out to jq because the bake + # metadata can exceed the shell ARG_MAX limit when inherited as an env + # var by an exec'd jq. + - name: Export digests uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: STEPS_BAKE_OUTPUTS_METADATA: ${{ steps.bake.outputs.metadata }} + ARCH: ${{ matrix.arch }} with: script: | + const fs = require('node:fs'); + const path = require('node:path'); const bakeOutput = JSON.parse(process.env.STEPS_BAKE_OUTPUTS_METADATA); - const metadata = {}; - for (const [key, value] of Object.entries(bakeOutput)) { - if (value && typeof value === 'object' && ('containerimage.digest' in value)) { - metadata[key] = { - digest: value['containerimage.digest'], - tags: value['image.name'].split(','), - }; + const arch = process.env.ARCH; + fs.mkdirSync('/tmp/digests', { recursive: true }); + for (const target of ['regular', 'debug']) { + const digest = bakeOutput[target]?.['containerimage.digest']; + if (!digest) { + throw new Error(`Missing containerimage.digest for target ${target}`); } + fs.writeFileSync(path.join('/tmp/digests', `${target}-${arch}`), digest); } - return metadata; + + - name: Upload digests + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: digests-${{ matrix.arch }} + path: /tmp/digests/* + retention-days: 1 + + finalize-image: + name: Create multi-arch manifests + if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow' + runs-on: ubuntu-24.04 + + needs: + - build-image + - compute-image-meta + + outputs: + metadata: ${{ steps.output.outputs.metadata }} + + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: digests-* + path: /tmp/digests + merge-multiple: true + + - name: Setup Cosign + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + # See `build-image` for why the Element OCI Registry login is gated on + # `push` events. + - name: Tailscale + if: github.event_name == 'push' + uses: tailscale/github-action@53acf823325fe9ca47f4cdaa951f90b4b0de5bb9 # v4.1.1 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + audience: ${{ secrets.TS_AUDIENCE }} + tags: tag:github-actions + + - name: Compute vault jwt role name + id: vault-jwt-role + if: github.event_name == 'push' + run: | + echo "role_name=github_service_management_$( echo "${{ github.repository }}" | sed -r 's|[/-]|_|g')" | tee -a "$GITHUB_OUTPUT" + + - name: Get team registry token + id: import-secrets + if: github.event_name == 'push' + uses: hashicorp/vault-action@4c06c5ccf5c0761b6029f56cfb1dcf5565918a3b # v3.4.0 + with: + url: https://vault.infra.ci.i.element.dev + role: ${{ steps.vault-jwt-role.outputs.role_name }} + path: service-management/github-actions + jwtGithubAudience: https://vault.infra.ci.i.element.dev + method: jwt + secrets: | + services/backend-repositories/secret/data/oci.element.io username | OCI_USERNAME ; + services/backend-repositories/secret/data/oci.element.io password | OCI_PASSWORD ; + + - name: Login to Element OCI Registry + if: github.event_name == 'push' + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: oci-push.vpn.infra.element.io + username: ${{ steps.import-secrets.outputs.OCI_USERNAME }} + password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }} + + - name: Create regular manifest + env: + META_JSON: ${{ needs.compute-image-meta.outputs.regular-json }} + run: | + REGULAR_AMD64=$(cat /tmp/digests/regular-amd64) + REGULAR_ARM64=$(cat /tmp/digests/regular-arm64) + # Build `-t TAG` and `--annotation index:NAME=VALUE` args into a bash + # array so that values containing spaces survive shell word-splitting. + # We keep the `index:` prefix so the annotations land on the index + # manifest itself rather than getting applied at the default manifest + # level. Empty-valued entries are filtered as a safety net (the bake + # step's annotation file is already pre-filtered upstream). + declare -a ARGS=() + while IFS= read -r tag; do + ARGS+=(-t "$tag") + done < <(jq -r '.tags[]' <<< "$META_JSON") + while IFS= read -r annotation; do + ARGS+=(--annotation "$annotation") + done < <(jq -r ' + .annotations + | map(select(startswith("index:"))) + | map(select(endswith("=") | not)) + | .[] + ' <<< "$META_JSON") + docker buildx imagetools create \ + "${ARGS[@]}" \ + "ghcr.io/element-hq/matrix-authentication-service@$REGULAR_AMD64" \ + "ghcr.io/element-hq/matrix-authentication-service@$REGULAR_ARM64" + + - name: Create debug manifest + env: + META_DEBUG_JSON: ${{ needs.compute-image-meta.outputs.debug-json }} + run: | + DEBUG_AMD64=$(cat /tmp/digests/debug-amd64) + DEBUG_ARM64=$(cat /tmp/digests/debug-arm64) + declare -a ARGS=() + while IFS= read -r tag; do + ARGS+=(-t "$tag") + done < <(jq -r '.tags[]' <<< "$META_DEBUG_JSON") + while IFS= read -r annotation; do + ARGS+=(--annotation "$annotation") + done < <(jq -r ' + .annotations + | map(select(startswith("index:"))) + | map(select(endswith("=") | not)) + | .[] + ' <<< "$META_DEBUG_JSON") + docker buildx imagetools create \ + "${ARGS[@]}" \ + "ghcr.io/element-hq/matrix-authentication-service@$DEBUG_AMD64" \ + "ghcr.io/element-hq/matrix-authentication-service@$DEBUG_ARM64" + + - name: Get manifest digests + id: manifests + env: + META_JSON: ${{ needs.compute-image-meta.outputs.regular-json }} + META_DEBUG_JSON: ${{ needs.compute-image-meta.outputs.debug-json }} + run: | + # Inspect the manifest list under the first tag to retrieve its digest + REGULAR_TAG=$(jq -r '.tags[0]' <<< "$META_JSON") + DEBUG_TAG=$(jq -r '.tags[0]' <<< "$META_DEBUG_JSON") + REGULAR_DIGEST=$(docker buildx imagetools inspect "$REGULAR_TAG" --format '{{ json . }}' | jq -r '.manifest.digest') + DEBUG_DIGEST=$(docker buildx imagetools inspect "$DEBUG_TAG" --format '{{ json . }}' | jq -r '.manifest.digest') + echo "regular=$REGULAR_DIGEST" >> $GITHUB_OUTPUT + echo "debug=$DEBUG_DIGEST" >> $GITHUB_OUTPUT - name: Sign the images with GitHub Actions provided token # Only sign on tags and on commits on main branch @@ -374,8 +584,8 @@ jobs: && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') env: - REGULAR_DIGEST: ${{ steps.metadata.outputs.result && fromJSON(steps.metadata.outputs.result).regular.digest }} - DEBUG_DIGEST: ${{ steps.metadata.outputs.result && fromJSON(steps.metadata.outputs.result).debug.digest }} + REGULAR_DIGEST: ${{ steps.manifests.outputs.regular }} + DEBUG_DIGEST: ${{ steps.manifests.outputs.debug }} run: |- cosign sign --yes \ @@ -385,13 +595,30 @@ jobs: "oci-push.vpn.infra.element.io/matrix-authentication-service@$REGULAR_DIGEST" \ "oci-push.vpn.infra.element.io/matrix-authentication-service@$DEBUG_DIGEST" + - name: Output metadata + id: output + env: + REGULAR_DIGEST: ${{ steps.manifests.outputs.regular }} + DEBUG_DIGEST: ${{ steps.manifests.outputs.debug }} + META_JSON: ${{ needs.compute-image-meta.outputs.regular-json }} + META_DEBUG_JSON: ${{ needs.compute-image-meta.outputs.debug-json }} + run: | + echo 'metadata<> $GITHUB_OUTPUT + jq -nc \ + --arg regular_digest "$REGULAR_DIGEST" \ + --arg debug_digest "$DEBUG_DIGEST" \ + --argjson regular_tags "$(jq '.tags' <<< "$META_JSON")" \ + --argjson debug_tags "$(jq '.tags' <<< "$META_DEBUG_JSON")" \ + '{regular: {digest: $regular_digest, tags: $regular_tags}, debug: {digest: $debug_digest, tags: $debug_tags}}' >> $GITHUB_OUTPUT + echo 'EOF' >> $GITHUB_OUTPUT + release: name: Release if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-24.04 needs: - assemble-archives - - build-image + - finalize-image steps: - name: Download the artifacts from the previous job @@ -412,12 +639,12 @@ jobs: - Digest: ``` - ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).regular.digest }} - oci.element.io/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).regular.digest }} + ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).regular.digest }} + oci.element.io/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).regular.digest }} ``` - Tags: ``` - ${{ join(fromJSON(needs.build-image.outputs.metadata).regular.tags, ' + ${{ join(fromJSON(needs.finalize-image.outputs.metadata).regular.tags, ' ') }} ``` @@ -425,12 +652,12 @@ jobs: - Digest: ``` - ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).debug.digest }} - oci.element.io/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).debug.digest }} + ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).debug.digest }} + oci.element.io/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).debug.digest }} ``` - Tags: ``` - ${{ join(fromJSON(needs.build-image.outputs.metadata).debug.tags, ' + ${{ join(fromJSON(needs.finalize-image.outputs.metadata).debug.tags, ' ') }} ``` @@ -446,7 +673,7 @@ jobs: needs: - assemble-archives - - build-image + - finalize-image permissions: contents: write @@ -492,12 +719,12 @@ jobs: - Digest: ``` - ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).regular.digest }} - oci.element.io/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).regular.digest }} + ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).regular.digest }} + oci.element.io/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).regular.digest }} ``` - Tags: ``` - ${{ join(fromJSON(needs.build-image.outputs.metadata).regular.tags, ' + ${{ join(fromJSON(needs.finalize-image.outputs.metadata).regular.tags, ' ') }} ``` @@ -505,12 +732,12 @@ jobs: - Digest: ``` - ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).debug.digest }} - oci.element.io/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).debug.digest }} + ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).debug.digest }} + oci.element.io/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).debug.digest }} ``` - Tags: ``` - ${{ join(fromJSON(needs.build-image.outputs.metadata).debug.tags, ' + ${{ join(fromJSON(needs.finalize-image.outputs.metadata).debug.tags, ' ') }} ``` @@ -526,7 +753,7 @@ jobs: if: github.event_name == 'pull_request' && github.event.label.name == 'Z-Build-Workflow' needs: - - build-image + - finalize-image permissions: contents: read @@ -543,7 +770,7 @@ jobs: - name: Remove label and comment uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: - BUILD_IMAGE_MANIFEST: ${{ needs.build-image.outputs.metadata }} + BUILD_IMAGE_MANIFEST: ${{ needs.finalize-image.outputs.metadata }} with: script: | const script = require('./.github/scripts/cleanup-pr.cjs'); diff --git a/Dockerfile b/Dockerfile index ba2ed16c8..1998779da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,9 @@ # Please see LICENSE files in the repository root for full details. # Builds a minimal image with the binary only. It is multi-arch capable, -# cross-building to aarch64 and x86_64. When cross-compiling, Docker sets two +# cross-building to aarch64 or x86_64. When cross-compiling, Docker sets two # implicit BUILDARG: BUILDPLATFORM being the host platform and TARGETPLATFORM -# being the platform being built. +# being the platform being built. Each architecture is built separately. # The Debian version and version name must be in sync ARG DEBIAN_VERSION=13 @@ -119,20 +119,25 @@ ENV SQLX_OFFLINE=true ARG VERGEN_GIT_DESCRIBE ENV VERGEN_GIT_DESCRIBE=${VERGEN_GIT_DESCRIBE} +ARG TARGETARCH + # Network access: cargo auditable needs it RUN --network=default \ --mount=type=cache,target=/root/.cargo/registry \ --mount=type=cache,target=/app/target \ + RUST_TARGET=$(case "${TARGETARCH}" in \ + amd64) echo "x86_64-unknown-linux-gnu" ;; \ + arm64) echo "aarch64-unknown-linux-gnu" ;; \ + *) echo "unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac) && \ cargo auditable build \ --locked \ --release \ --bin mas-cli \ --no-default-features \ --features docker \ - --target x86_64-unknown-linux-gnu \ - --target aarch64-unknown-linux-gnu \ - && mv "target/x86_64-unknown-linux-gnu/release/mas-cli" /usr/local/bin/mas-cli-amd64 \ - && mv "target/aarch64-unknown-linux-gnu/release/mas-cli" /usr/local/bin/mas-cli-arm64 + --target "${RUST_TARGET}" \ + && mv "target/${RUST_TARGET}/release/mas-cli" /usr/local/bin/mas-cli ####################################### ## Prepare /usr/local/share/mas-cli/ ## @@ -149,8 +154,7 @@ COPY ./translations/ /share/translations ################################## FROM gcr.io/distroless/cc-debian${DEBIAN_VERSION}:debug-nonroot AS debug -ARG TARGETARCH -COPY --from=builder /usr/local/bin/mas-cli-${TARGETARCH} /usr/local/bin/mas-cli +COPY --from=builder /usr/local/bin/mas-cli /usr/local/bin/mas-cli COPY --from=share /share /usr/local/share/mas-cli WORKDIR / @@ -161,8 +165,7 @@ ENTRYPOINT ["/usr/local/bin/mas-cli"] ################### FROM gcr.io/distroless/cc-debian${DEBIAN_VERSION}:nonroot -ARG TARGETARCH -COPY --from=builder /usr/local/bin/mas-cli-${TARGETARCH} /usr/local/bin/mas-cli +COPY --from=builder /usr/local/bin/mas-cli /usr/local/bin/mas-cli COPY --from=share /share /usr/local/share/mas-cli WORKDIR / diff --git a/docker-bake.hcl b/docker-bake.hcl index 3c3cac3af..6f6879e1a 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -1,3 +1,4 @@ +# Copyright 2025, 2026 Element Creations Ltd. # Copyright 2025 New Vector Ltd. # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -15,8 +16,8 @@ group "default" { targets = ["regular", "debug"] } target "docker-metadata-action" {} target "docker-metadata-action-debug" {} -// This sets the platforms and is further extended by GitHub Actions to set the -// output and the cache locations +// This is extended by GitHub Actions to set the output, cache locations, +// and platforms (one architecture per job for parallel builds) target "base" { args = { // This is set so that when we use a git context, the .git directory is @@ -26,11 +27,6 @@ target "base" { // Pass down the version from an external git describe source VERGEN_GIT_DESCRIBE = "${VERGEN_GIT_DESCRIBE}" } - - platforms = [ - "linux/amd64", - "linux/arm64", - ] } target "regular" {