fix: Scope image build credentials #49

Manually merged
day01 merged 1 commits from fix/0.6-scoped-image-credentials into develop 2026-08-30 17:00:59 +00:00
2 changed files with 10 additions and 20 deletions
Showing only changes of commit 01107da78e - Show all commits
+7 -14
View File
@@ -1,61 +1,54 @@
ARG SOURCE_DATE_EPOCH=0

FROM rust:1.95.0-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 AS source

WORKDIR /src
COPY . .

# The test suite is deliberately absent here. Its store tests refuse to pass
# without a Postgres to run against, and an image build has none; running the
# rest and calling it validation would say "verified" about the part that is
# not. The full suite, database included, runs in pull-request.yml.
FROM source AS validation
ARG TARGETARCH
RUN --mount=type=cache,id=syncode-control-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=syncode-control-validation-${TARGETARCH},target=/src/target,sharing=locked \
--mount=type=secret,id=syncode_git_user,env=SYNCODE_GIT_USER \
--mount=type=secret,id=syncode_git_token,env=SYNCODE_GIT_TOKEN \
AUTHORIZATION="$(printf '%s:%s' "${SYNCODE_GIT_USER}" "${SYNCODE_GIT_TOKEN}" | base64 | tr -d '\n')" \
&& git config --global http.https://syncode.sh/.extraheader "AUTHORIZATION: basic ${AUTHORIZATION}" \
&& cargo fmt --check \
&& cargo clippy --workspace --all-targets --all-features -- -D warnings \
&& ./scripts/check-architecture.sh \
&& ./scripts/check-rust-loc.sh \
&& git config --global --unset-all http.https://syncode.sh/.extraheader

FROM source AS builder
ARG TARGETARCH
RUN --mount=type=cache,id=syncode-control-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=syncode-control-release-${TARGETARCH},target=/src/target,sharing=locked \
--mount=type=secret,id=syncode_git_user,env=SYNCODE_GIT_USER \
--mount=type=secret,id=syncode_git_token,env=SYNCODE_GIT_TOKEN \
AUTHORIZATION="$(printf '%s:%s' "${SYNCODE_GIT_USER}" "${SYNCODE_GIT_TOKEN}" | base64 | tr -d '\n')" \
&& git config --global http.https://syncode.sh/.extraheader "AUTHORIZATION: basic ${AUTHORIZATION}" \
&& cargo build --locked --release \
&& install -D -m 0755 target/release/syncode-control /out/syncode-control \
&& git config --global --unset-all http.https://syncode.sh/.extraheader

FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818

ARG VERSION=dev

LABEL org.opencontainers.image.source="https://syncode.sh/syncode/control"
LABEL org.opencontainers.image.version="${VERSION}"

COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /out/syncode-control /usr/local/bin/syncode-control

# Nodes open their session on one port and the forge delivers events on the
# other. They are separate because they are addressed differently: one is
# reached by runners, the other only by the forge.
EXPOSE 8090 8091

USER nobody

ENTRYPOINT ["/usr/local/bin/syncode-control"]

# The binary defaults to the loopback, which is right when someone runs it on
# their own machine and useless inside a container: the network is the only way
# in. The image therefore listens on every interface, and the container's own
# isolation is what keeps that private.
CMD ["--listen", "0.0.0.0:8090", "--listen-events", "0.0.0.0:8091"]
ARG SOURCE_DATE_EPOCH=0

FROM rust:1.95.0-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 AS source

WORKDIR /src
COPY . .

# The test suite is deliberately absent here. Its store tests refuse to pass
# without a Postgres to run against, and an image build has none; running the
# rest and calling it validation would say "verified" about the part that is
# not. The full suite, database included, runs in pull-request.yml.
FROM source AS validation
ARG TARGETARCH
RUN --mount=type=cache,id=syncode-control-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=syncode-control-validation-${TARGETARCH},target=/src/target,sharing=locked \
--mount=type=secret,id=syncode_git_config,target=/root/.gitconfig,required=true \
cargo fmt --check \
&& cargo clippy --workspace --all-targets --all-features -- -D warnings \
&& cargo test --workspace --all-targets --all-features \
&& ./scripts/check-architecture.sh \
&& ./scripts/check-rust-loc.sh

FROM source AS builder
ARG TARGETARCH
RUN --mount=type=cache,id=syncode-control-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=syncode-control-release-${TARGETARCH},target=/src/target,sharing=locked \
--mount=type=secret,id=syncode_git_config,target=/root/.gitconfig,required=true \
cargo build --locked --release \
&& install -D -m 0755 target/release/syncode-control /out/syncode-control

FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818

ARG VERSION=dev

LABEL org.opencontainers.image.source="https://syncode.sh/syncode/control"
LABEL org.opencontainers.image.version="${VERSION}"

COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /out/syncode-control /usr/local/bin/syncode-control

# Nodes open their session on one port and the forge delivers events on the
# other. They are separate because they are addressed differently: one is
# reached by runners, the other only by the forge.
EXPOSE 8090 8091

USER nobody

ENTRYPOINT ["/usr/local/bin/syncode-control"]

# The binary defaults to the loopback, which is right when someone runs it on
# their own machine and useless inside a container: the network is the only way
# in. The image therefore listens on every interface, and the container's own
# isolation is what keeps that private.
CMD ["--listen", "0.0.0.0:8090", "--listen-events", "0.0.0.0:8091"]
+3 -6
View File
@@ -1,224 +1,221 @@
name: build-image

on:
push:
branches: [develop, main]
tags: ["v*"]
workflow_dispatch:

concurrency:
group: control-build-image-${{ github.ref_name }}
cancel-in-progress: ${{ github.ref_type != 'tag' }}

env:
CONTROL_IMAGE: syncode.sh/syncode/control

jobs:
resolve:
runs-on: ubuntu-latest
outputs:
operation: ${{ steps.release.outputs.operation }}
product_version: ${{ steps.release.outputs.product_version }}
source: ${{ steps.release.outputs.source }}
tags: ${{ steps.release.outputs.tags }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- id: release
name: Resolve release
run: |
set -euo pipefail
SHA="$(git rev-parse --short HEAD)"
BASE_VERSION="$(tr -d '[:space:]' < VERSION)"
OPERATION=build
NAMES=
case "${GITHUB_REF}" in
refs/tags/*)
VERSION="${BASE_VERSION}"
test "${GITHUB_REF_NAME#v}" = "${BASE_VERSION}"
OPERATION=promote
;;
refs/heads/develop)
VERSION="${BASE_VERSION}-dev"
NAMES="${SHA} develop ${VERSION}"
;;
refs/heads/main)
VERSION="${BASE_VERSION}"
NAMES="${SHA} main latest"
;;
*)
echo "unsupported ref: ${GITHUB_REF}"
exit 1
;;
esac
# The digest of the sources the image is built from, so a rebuild of
# the same tree reuses the published image instead of producing a
# second one that differs only by timestamp.
HASH="$( { git ls-files -s -- . \
':(exclude).gitea/**' ':(exclude)*.md' ':(exclude)LICENSE'
printf 'version=%s\n' "${VERSION}"; } | git hash-object --stdin | cut -c1-12)"
refs() {
local separator=""
for name in "$@"; do
printf '%s%s:%s' "${separator}" "${CONTROL_IMAGE}" "${name}"
separator=,
done
}
{
echo "operation=${OPERATION}"
echo "product_version=${VERSION}"
echo "source=${CONTROL_IMAGE}:src-${HASH}"
echo "tags=$(refs ${NAMES})"
} >> "${GITHUB_OUTPUT}"

validate:
needs: resolve
if: ${{ needs.resolve.outputs.operation == 'build' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: .
target: validation
outputs: type=cacheonly
secrets: |
"syncode_git_user=${{ secrets.REGISTRY_USER }}"
"syncode_git_token=${{ secrets.REGISTRY_TOKEN }}"

published:
needs: resolve
if: ${{ needs.resolve.outputs.operation == 'build' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- id: check
name: Look for the image in the registry
env:
SOURCE: ${{ needs.resolve.outputs.source }}
run: |
set -euo pipefail
if docker buildx imagetools inspect "${SOURCE}" >/dev/null 2>&1; then
echo "exists=true" >> "${GITHUB_OUTPUT}"
else
echo "exists=false" >> "${GITHUB_OUTPUT}"
fi

# Each architecture builds natively on a runner labeled for it, rather than
# one job cross-building both under QEMU — arm64 under emulation was slow
# enough to stall the whole CI queue behind it (meta issue #42).
image-amd64:
needs: [resolve, validate, published]
if: ${{ needs.resolve.outputs.operation == 'build' && needs.published.outputs.exists == 'false' }}
runs-on: syncode-linux-amd64
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: .
platforms: linux/amd64
provenance: false
build-args: |
SOURCE_DATE_EPOCH=0
VERSION=${{ needs.resolve.outputs.product_version }}
tags: ${{ needs.resolve.outputs.source }}-amd64
cache-from: type=registry,ref=syncode.sh/syncode/control:buildcache-amd64
cache-to: type=registry,ref=syncode.sh/syncode/control:buildcache-amd64,mode=max
outputs: type=registry,rewrite-timestamp=true
secrets: |
"syncode_git_user=${{ secrets.REGISTRY_USER }}"
"syncode_git_token=${{ secrets.REGISTRY_TOKEN }}"

image-arm64:
needs: [resolve, validate, published]
if: ${{ needs.resolve.outputs.operation == 'build' && needs.published.outputs.exists == 'false' }}
runs-on: syncode-linux-arm64
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: .
platforms: linux/arm64
provenance: false
build-args: |
SOURCE_DATE_EPOCH=0
VERSION=${{ needs.resolve.outputs.product_version }}
tags: ${{ needs.resolve.outputs.source }}-arm64
cache-from: type=registry,ref=syncode.sh/syncode/control:buildcache-arm64
cache-to: type=registry,ref=syncode.sh/syncode/control:buildcache-arm64,mode=max
outputs: type=registry,rewrite-timestamp=true
secrets: |
"syncode_git_user=${{ secrets.REGISTRY_USER }}"
"syncode_git_token=${{ secrets.REGISTRY_TOKEN }}"

image:
needs: [resolve, published, image-amd64, image-arm64]
if: ${{ needs.resolve.outputs.operation == 'build' }}
runs-on: ubuntu-latest
steps:
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- name: Combine per-arch images and point the release tags at them
env:
SOURCE: ${{ needs.resolve.outputs.source }}
TAGS: ${{ needs.resolve.outputs.tags }}
EXISTS: ${{ needs.published.outputs.exists }}
run: |
set -euo pipefail
if [ "${EXISTS}" = "false" ]; then
docker buildx imagetools create --tag "${SOURCE}" "${SOURCE}-amd64" "${SOURCE}-arm64"
fi
for REF in ${TAGS//,/ }; do
docker buildx imagetools create --tag "${REF}" "${SOURCE}"
done

promote:
needs: resolve
if: ${{ needs.resolve.outputs.operation == 'promote' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- name: Promote release manifests
env:
PRODUCT_VERSION: ${{ needs.resolve.outputs.product_version }}
run: |
set -euo pipefail
MAIN_SHA="$(git ls-remote origin refs/heads/main | awk 'NR == 1 {print $1}')"
test -n "${MAIN_SHA}"
test "$(git rev-parse HEAD)" = "${MAIN_SHA}"
docker buildx imagetools create \
--tag "${CONTROL_IMAGE}:${PRODUCT_VERSION}" \
"${CONTROL_IMAGE}:main"
name: build-image

on:
push:
branches: [develop, main]
tags: ["v*"]
workflow_dispatch:

concurrency:
group: control-build-image-${{ github.ref_name }}
cancel-in-progress: ${{ github.ref_type != 'tag' }}

env:
CONTROL_IMAGE: syncode.sh/syncode/control

jobs:
resolve:
runs-on: ubuntu-latest
outputs:
operation: ${{ steps.release.outputs.operation }}
product_version: ${{ steps.release.outputs.product_version }}
source: ${{ steps.release.outputs.source }}
tags: ${{ steps.release.outputs.tags }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- id: release
name: Resolve release
run: |
set -euo pipefail
SHA="$(git rev-parse --short HEAD)"
BASE_VERSION="$(tr -d '[:space:]' < VERSION)"
OPERATION=build
NAMES=
case "${GITHUB_REF}" in
refs/tags/*)
VERSION="${BASE_VERSION}"
test "${GITHUB_REF_NAME#v}" = "${BASE_VERSION}"
OPERATION=promote
;;
refs/heads/develop)
VERSION="${BASE_VERSION}-dev"
NAMES="${SHA} develop ${VERSION}"
;;
refs/heads/main)
VERSION="${BASE_VERSION}"
NAMES="${SHA} main latest"
;;
*)
echo "unsupported ref: ${GITHUB_REF}"
exit 1
;;
esac
# The digest of the sources the image is built from, so a rebuild of
# the same tree reuses the published image instead of producing a
# second one that differs only by timestamp.
HASH="$( { git ls-files -s -- . \
':(exclude).gitea/**' ':(exclude)*.md' ':(exclude)LICENSE'
printf 'version=%s\n' "${VERSION}"; } | git hash-object --stdin | cut -c1-12)"
refs() {
local separator=""
for name in "$@"; do
printf '%s%s:%s' "${separator}" "${CONTROL_IMAGE}" "${name}"
separator=,
done
}
{
echo "operation=${OPERATION}"
echo "product_version=${VERSION}"
echo "source=${CONTROL_IMAGE}:src-${HASH}"
echo "tags=$(refs ${NAMES})"
} >> "${GITHUB_OUTPUT}"

validate:
needs: resolve
if: ${{ needs.resolve.outputs.operation == 'build' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: .
target: validation
outputs: type=cacheonly
secrets: |
"syncode_git_config=${{ secrets.SYNCODE_GIT_CONFIG }}"

published:
needs: resolve
if: ${{ needs.resolve.outputs.operation == 'build' }}
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.check.outputs.exists }}
steps:
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- id: check
name: Look for the image in the registry
env:
SOURCE: ${{ needs.resolve.outputs.source }}
run: |
set -euo pipefail
if docker buildx imagetools inspect "${SOURCE}" >/dev/null 2>&1; then
echo "exists=true" >> "${GITHUB_OUTPUT}"
else
echo "exists=false" >> "${GITHUB_OUTPUT}"
fi

# Each architecture builds natively on a runner labeled for it, rather than
# one job cross-building both under QEMU — arm64 under emulation was slow
# enough to stall the whole CI queue behind it (meta issue #42).
image-amd64:
needs: [resolve, validate, published]
if: ${{ needs.resolve.outputs.operation == 'build' && needs.published.outputs.exists == 'false' }}
runs-on: syncode-linux-amd64
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: .
platforms: linux/amd64
provenance: false
build-args: |
SOURCE_DATE_EPOCH=0
VERSION=${{ needs.resolve.outputs.product_version }}
tags: ${{ needs.resolve.outputs.source }}-amd64
cache-from: type=registry,ref=syncode.sh/syncode/control:buildcache-amd64
cache-to: type=registry,ref=syncode.sh/syncode/control:buildcache-amd64,mode=max
outputs: type=registry,rewrite-timestamp=true
secrets: |
"syncode_git_config=${{ secrets.SYNCODE_GIT_CONFIG }}"

image-arm64:
needs: [resolve, validate, published]
if: ${{ needs.resolve.outputs.operation == 'build' && needs.published.outputs.exists == 'false' }}
runs-on: syncode-linux-arm64
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: .
platforms: linux/arm64
provenance: false
build-args: |
SOURCE_DATE_EPOCH=0
VERSION=${{ needs.resolve.outputs.product_version }}
tags: ${{ needs.resolve.outputs.source }}-arm64
cache-from: type=registry,ref=syncode.sh/syncode/control:buildcache-arm64
cache-to: type=registry,ref=syncode.sh/syncode/control:buildcache-arm64,mode=max
outputs: type=registry,rewrite-timestamp=true
secrets: |
"syncode_git_config=${{ secrets.SYNCODE_GIT_CONFIG }}"

image:
needs: [resolve, published, image-amd64, image-arm64]
if: ${{ needs.resolve.outputs.operation == 'build' }}
runs-on: ubuntu-latest
steps:
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- name: Combine per-arch images and point the release tags at them
env:
SOURCE: ${{ needs.resolve.outputs.source }}
TAGS: ${{ needs.resolve.outputs.tags }}
EXISTS: ${{ needs.published.outputs.exists }}
run: |
set -euo pipefail
if [ "${EXISTS}" = "false" ]; then
docker buildx imagetools create --tag "${SOURCE}" "${SOURCE}-amd64" "${SOURCE}-arm64"
fi
for REF in ${TAGS//,/ }; do
docker buildx imagetools create --tag "${REF}" "${SOURCE}"
done

promote:
needs: resolve
if: ${{ needs.resolve.outputs.operation == 'promote' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262

- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: syncode.sh
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}

- name: Promote release manifests
env:
PRODUCT_VERSION: ${{ needs.resolve.outputs.product_version }}
run: |
set -euo pipefail
MAIN_SHA="$(git ls-remote origin refs/heads/main | awk 'NR == 1 {print $1}')"
test -n "${MAIN_SHA}"
test "$(git rev-parse HEAD)" = "${MAIN_SHA}"
docker buildx imagetools create \
--tag "${CONTROL_IMAGE}:${PRODUCT_VERSION}" \
"${CONTROL_IMAGE}:main"