conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

publish.sh (10822B)


      1 #!/bin/sh
      2 # deploy/publish.sh - build and push the images to Docker Hub
      3 #
      4 # Run by the publish job in .conductor.yml, which restricts it to main and
      5 # to release tags. Safe to run by hand for a dry run.
      6 #
      7 # What gets pushed, from CONDUCTOR_REF:
      8 #
      9 #   refs/heads/main    finwo/conductor:main   and :latest
     10 #   refs/heads/topic   finwo/conductor:topic
     11 #   refs/tags/v1.2.0   finwo/conductor:1.2.0  and :1.2
     12 #
     13 # A branch publishes its own name. main carries :latest on top of that,
     14 # and nothing else ever moves it, so :latest is always what main is.
     15 #
     16 # Needs REGISTRY_USERNAME and REGISTRY_TOKEN as project variables. They are
     17 # masked in the log by the conductor, but this still keeps them off the
     18 # command line, where they would show up in a process list.
     19 #
     20 #   DRY_RUN=1  build for the host architecture only, and push nothing
     21 
     22 set -eu
     23 
     24 : "${CONDUCTOR_REF:?CONDUCTOR_REF is not set}"
     25 : "${CONDUCTOR_SHA:?CONDUCTOR_SHA is not set}"
     26 
     27 DRY_RUN="${DRY_RUN:-0}"
     28 CONDUCTOR_IMAGE="${CONDUCTOR_IMAGE:-finwo/conductor}"
     29 WORKER_IMAGE="${WORKER_IMAGE:-finwo/conductor-worker}"
     30 
     31 # riscv64 has no node image and no static docker CLI, which is why both
     32 # Dockerfiles are built on Alpine. Everything here is available there.
     33 PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64,linux/riscv64}"
     34 
     35 ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
     36 SHORT=$(printf '%s' "${CONDUCTOR_SHA}" | cut -c1-12)
     37 
     38 log() { printf '\n== %s\n' "$*"; }
     39 fail() { printf '\nFAILED: %s\n' "$*" >&2; exit 1; }
     40 
     41 # A docker tag is [A-Za-z0-9_][A-Za-z0-9._-]*, so a branch such as
     42 # feature/x cannot be used as it stands.
     43 docker_tag() {
     44     tag=$(printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '-' | cut -c1-128)
     45     case "${tag}" in
     46         [!A-Za-z0-9_]*) tag="x${tag}" ;;
     47     esac
     48     printf '%s\n' "${tag}"
     49 }
     50 
     51 # What a branch publishes on top of its own name. Give another branch an
     52 # alias, a pre-release series for instance, by adding a line here.
     53 branch_aliases() {
     54     case "$1" in
     55         main) printf 'latest\n' ;;
     56     esac
     57 }
     58 
     59 # Turns a ref into the list of tags it should publish.
     60 #
     61 # A version tag also publishes the major.minor series, so v1.2.1 moves
     62 # :1.2 as well. Deliberately no bare major: :1 moving across minor
     63 # releases tends to surprise people more than it helps them.
     64 tags_for_ref() {
     65     ref=$1
     66     case "${ref}" in
     67         refs/heads/*)
     68             branch=${ref#refs/heads/}
     69             docker_tag "${branch}"
     70             branch_aliases "${branch}"
     71             ;;
     72         refs/tags/v*)
     73             version=${ref#refs/tags/v}
     74             case "${version}" in
     75                 *[!0-9.]*)
     76                     fail "tag ${ref} is not a plain version, refusing to guess what to publish"
     77                     ;;
     78             esac
     79             printf '%s\n' "${version}"
     80             # v1.2.3 -> 1.2, but v1.2 stays as it is.
     81             series=$(printf '%s' "${version}" | cut -d. -f1-2)
     82             [ "${series}" = "${version}" ] || printf '%s\n' "${series}"
     83             ;;
     84         *)
     85             fail "ref ${ref} does not publish anything; the only: rule in .conductor.yml should have prevented this"
     86             ;;
     87     esac
     88 }
     89 
     90 TAGS=$(tags_for_ref "${CONDUCTOR_REF}")
     91 
     92 log "publishing from ${CONDUCTOR_REF} at ${SHORT}"
     93 printf 'tags:\n'
     94 printf '  %s\n' ${TAGS}
     95 printf 'platforms: %s\n' "${PLATFORMS}"
     96 
     97 # Collects --tag arguments for one image.
     98 tag_args() {
     99     image=$1
    100     for tag in ${TAGS}; do
    101         printf -- '--tag\n%s:%s\n' "${image}" "${tag}"
    102     done
    103 }
    104 
    105 if [ "${DRY_RUN}" = 1 ]; then
    106     log "dry run: building for this architecture only, pushing nothing"
    107     for spec in "deploy/Dockerfile ${CONDUCTOR_IMAGE}" "deploy/Dockerfile.worker ${WORKER_IMAGE}"; do
    108         # shellcheck disable=SC2086
    109         set -- ${spec}
    110         dockerfile=$1
    111         image=$2
    112         printf '\n-- %s\n' "${image}"
    113         docker build -f "${ROOT}/${dockerfile}" -t "${image}:dry-run" "${ROOT}" >/dev/null
    114         printf 'would push:\n'
    115         for tag in ${TAGS}; do printf '  %s:%s\n' "${image}" "${tag}"; done
    116     done
    117     printf '\nPASSED (dry run)\n'
    118     exit 0
    119 fi
    120 
    121 # In CI the credentials come from project variables. Run by hand on a
    122 # machine where somebody has already signed in, there is nothing to
    123 # supply, and demanding a token again would be busywork.
    124 if [ -n "${REGISTRY_TOKEN:-}" ]; then
    125     : "${REGISTRY_USERNAME:?REGISTRY_USERNAME is set without REGISTRY_TOKEN}"
    126     SIGN_IN=1
    127 elif docker system info 2>/dev/null | grep -q '^ *Username:'; then
    128     SIGN_IN=0
    129     log "using the existing docker login for $(docker system info 2>/dev/null | sed -n 's/^ *Username: *//p')"
    130 else
    131     fail "not signed in to a registry, and REGISTRY_TOKEN is not set.
    132   Either export REGISTRY_USERNAME and REGISTRY_TOKEN, or run:
    133     docker login -u <user>"
    134 fi
    135 
    136 log "registering emulators"
    137 # arm64 and riscv64 are emulated unless the worker is that architecture.
    138 # Without this, buildx cannot run the foreign binaries a build needs.
    139 #
    140 # This reports success even when it achieved nothing: if the host has no
    141 # binfmt_misc mounted, the registrations go into the container's own mount
    142 # namespace and vanish with it. The check further down is what actually
    143 # decides whether emulation works, rather than this command's exit code.
    144 docker run --privileged --rm tonistiigi/binfmt:qemu-v9.2.2 --install all >/dev/null
    145 
    146 # Whether the builder can really produce every platform we are asking for.
    147 builder_covers() {
    148     available=$(docker buildx inspect "$1" 2>/dev/null | sed -n 's/^Platforms:[[:space:]]*//p')
    149     for want in $(printf '%s' "${PLATFORMS}" | tr ',' ' '); do
    150         # Commas around the list so linux/arm does not match linux/arm64.
    151         printf ',%s,' "${available}" | tr -d ' ' | grep -q ",${want}," || return 1
    152     done
    153     return 0
    154 }
    155 
    156 # Platform coverage is necessary but not sufficient: with the emulators
    157 # registered even the default builder claims every architecture, and then
    158 # refuses the build because the docker driver cannot produce more than one
    159 # platform at a time. The driver is the part that decides.
    160 builder_usable() {
    161     docker buildx inspect "$1" 2>/dev/null | grep -q '^Driver:[[:space:]]*docker-container' \
    162         && builder_covers "$1"
    163 }
    164 
    165 log "preparing the builder"
    166 # A builder bootstrapped before the emulators were registered does not pick
    167 # them up afterwards, so one that cannot do the job is replaced rather than
    168 # reused.
    169 if docker buildx inspect conductor-publish >/dev/null 2>&1 && ! builder_usable conductor-publish; then
    170     log "the existing builder cannot do this, replacing it"
    171     docker buildx rm conductor-publish >/dev/null 2>&1 || true
    172 fi
    173 
    174 docker buildx inspect conductor-publish >/dev/null 2>&1 \
    175     || docker buildx create --name conductor-publish --driver docker-container --bootstrap >/dev/null
    176 docker buildx use conductor-publish
    177 
    178 builder_usable conductor-publish || fail "the builder cannot produce ${PLATFORMS}.
    179   It reports: $(docker buildx inspect conductor-publish 2>/dev/null | sed -n 's/^Platforms:[[:space:]]*//p')
    180   Emulation is registered through binfmt_misc, which has to be mounted on
    181   the host running the docker daemon:
    182     sudo mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc
    183   Without it the installer above succeeds and registers nothing, and the
    184   build fails later with 'exec format error'."
    185 
    186 if [ "${SIGN_IN}" = 1 ]; then
    187     log "signing in"
    188     printf '%s' "${REGISTRY_TOKEN}" | docker login --username "${REGISTRY_USERNAME}" --password-stdin >/dev/null
    189 
    190     # Whatever happens next, do not leave the credentials behind on a
    191     # worker that is shared with other people's jobs. Only when this
    192     # script created the session: logging out of one somebody else
    193     # established would be rude, and on a workstation, baffling.
    194     cleanup() { docker logout >/dev/null 2>&1 || true; }
    195     trap cleanup EXIT
    196 fi
    197 
    198 log "building and pushing ${CONDUCTOR_IMAGE}"
    199 # shellcheck disable=SC2046
    200 docker buildx build \
    201     --platform "${PLATFORMS}" \
    202     --file "${ROOT}/deploy/Dockerfile" \
    203     $(tag_args "${CONDUCTOR_IMAGE}" | tr '\n' ' ') \
    204     --push \
    205     "${ROOT}"
    206 
    207 log "building and pushing ${WORKER_IMAGE}"
    208 # shellcheck disable=SC2046
    209 docker buildx build \
    210     --platform "${PLATFORMS}" \
    211     --file "${ROOT}/deploy/Dockerfile.worker" \
    212     $(tag_args "${WORKER_IMAGE}" | tr '\n' ' ') \
    213     --push \
    214     "${ROOT}"
    215 
    216 # Pushing an image does not touch the repository page, so the readme is
    217 # sent separately or Docker Hub shows nothing at all. Kept next to the
    218 # Dockerfiles so it is reviewed with them rather than edited in a web
    219 # form and forgotten.
    220 #
    221 # Needs an API token rather than the registry session, so this is skipped
    222 # when the script is run against an existing docker login. The release
    223 # itself is done by then, and a stale readme is not worth failing it for.
    224 sync_readme() {
    225     image=$1
    226     file=$2
    227     repo=${image#*/}
    228     namespace=${image%%/*}
    229 
    230     [ -f "${file}" ] || { log "no readme at ${file}, skipping"; return 0; }
    231 
    232     if [ -z "${REGISTRY_TOKEN:-}" ]; then
    233         log "no REGISTRY_TOKEN, leaving the ${image} readme alone"
    234         return 0
    235     fi
    236 
    237     if ! command -v curl >/dev/null 2>&1; then
    238         log "curl is missing, leaving the ${image} readme alone"
    239         return 0
    240     fi
    241 
    242     api=$(curl -fsS -X POST https://hub.docker.com/v2/auth/token \
    243         -H 'Content-Type: application/json' \
    244         -d "{\"identifier\":\"${REGISTRY_USERNAME}\",\"secret\":\"${REGISTRY_TOKEN}\"}" \
    245         | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
    246 
    247     [ -n "${api}" ] || { log "could not get a hub api token, leaving the readme alone"; return 0; }
    248 
    249     # The body is json, so the markdown has to be escaped rather than
    250     # pasted: it is full of quotes, backslashes and newlines.
    251     payload=$(FILE="${file}" awk '
    252         BEGIN { printf "{\"full_description\":\"" }
    253         {
    254             line = $0
    255             gsub(/\\/, "\\\\", line)
    256             gsub(/"/, "\\\"", line)
    257             gsub(/\t/, "\\t", line)
    258             printf "%s\\n", line
    259         }
    260         END { printf "\"}" }
    261     ' "${file}")
    262 
    263     if printf '%s' "${payload}" | curl -fsS -X PATCH \
    264         "https://hub.docker.com/v2/repositories/${namespace}/${repo}/" \
    265         -H 'Content-Type: application/json' \
    266         -H "Authorization: Bearer ${api}" \
    267         --data-binary @- >/dev/null
    268     then
    269         log "updated the ${image} readme"
    270     else
    271         log "could not update the ${image} readme"
    272     fi
    273 }
    274 
    275 log "updating the repository pages"
    276 sync_readme "${CONDUCTOR_IMAGE}" "${ROOT}/deploy/hub/conductor.md"
    277 sync_readme "${WORKER_IMAGE}" "${ROOT}/deploy/hub/conductor-worker.md"
    278 
    279 log "verifying the manifests"
    280 for image in "${CONDUCTOR_IMAGE}" "${WORKER_IMAGE}"; do
    281     first=$(printf '%s' "${TAGS}" | head -1)
    282     docker buildx imagetools inspect "${image}:${first}" \
    283         | grep -i platform \
    284         || fail "no platforms reported for ${image}:${first}"
    285 done
    286 
    287 printf '\nPASSED\n'