diff --git a/AGENTS.md b/AGENTS.md index 06a09ae..4a96b82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Logs into the container registry, sets up SSH key for `docker-build.haven`, crea - `image` (required) — full image ref without tag - `registry_password` (required) — registry password/token (use secrets) - `ssh_key` (required) — SSH private key for remote builder (use secrets) -- `image_tag` (default `latest`) — tag to apply to the built image +- `image_tag` (default `latest`) — comma-separated tag(s) to apply to the built image (e.g. `latest,abc1234`) - `registry_host` (default `git.ivanch.me`) — container registry hostname - `registry_username` (default `ivanch`) — registry username - `platforms` (default `linux/amd64,linux/arm64`) — target build platforms @@ -44,6 +44,8 @@ SSH into a remote host, pull the latest images with `docker compose pull`, and r - `remote_dir` (required) — directory on the remote server containing docker-compose.yml - `ssh_port` (default `22`) — SSH port - `compose_files` (default empty) — space-separated compose file paths (applied via -f flags) +- `image` (default empty) — image base (host/path/name, no tag); when set, every compose service matching this base is pinned to `image_tag` at deploy time +- `image_tag` (default `latest`) — tag applied to `image` when set (single tag; no comma-lists) ## Workflow Template for New Projects diff --git a/README.md b/README.md index 287ec27..93c61b5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ jobs: | `image` | ✅ | — | Full image reference without tag | | `registry_password` | ✅ | — | Registry password/token (use secrets) | | `ssh_key` | ✅ | — | SSH private key for the remote builder (use secrets) | -| `image_tag` | ❌ | `latest` | Tag to apply to the built image | +| `image_tag` | ❌ | `latest` | Comma-separated tag(s) to apply to the built image (e.g. `latest,abc1234`). Pushing a commit-pinned tag alongside `:latest` is how Docker-only deploys consume per-commit tags. | | `registry_host` | ❌ | `git.ivanch.me` | Container registry hostname | | `registry_username` | ❌ | `ivanch` | Registry username | | `platforms` | ❌ | `linux/amd64,linux/arm64` | Target build platforms | @@ -91,6 +91,8 @@ jobs: # optional: # ssh_port: "22" # compose_files: docker-compose.yml compose.override.yml + # image: git.ivanch.me/ivanch/my-app # pins matching services + # image_tag: latest # Docker-only custom tag ``` | Input | Required | Default | Description | @@ -101,6 +103,8 @@ jobs: | `remote_dir` | ✅ | — | Directory on the remote server with `docker-compose.yml` | | `ssh_port` | ❌ | `22` | SSH port | | `compose_files` | ❌ | _(empty)_ | Space-separated compose file paths (applied via `-f` flags; when empty docker compose uses default detection) | +| `image` | ❌ | _(empty)_ | Image base (host/path/name, **without tag**). When set, every compose service whose image matches this base is pinned to `image_tag` at deploy time. **Docker-only** — k8s deploys use `deploy-restart`, not this. | +| `image_tag` | ❌ | `latest` | Tag applied to `image` when `image` is set. (Single tag — comma-lists are not supported for deploys.) | --- diff --git a/build-and-push/action.yaml b/build-and-push/action.yaml index be1169c..b73b544 100644 --- a/build-and-push/action.yaml +++ b/build-and-push/action.yaml @@ -17,7 +17,7 @@ inputs: description: Full image reference (host/path/name) without tag required: true image_tag: - description: Tag to apply to the built image + description: Tag(s) to apply to the built image. Comma-separated for multiple tags (e.g. "latest,abc1234"). required: false default: latest platforms: @@ -90,4 +90,4 @@ runs: builder: remote-builder platforms: ${{ inputs.platforms }} tags: | - ${{ inputs.image }}:${{ inputs.image_tag }} + ${{ join(fromJSON(format('["{0}:{1}"]', inputs.image, join(split(inputs.image_tag, ','), format('","{0}:', inputs.image)))), '\n') }} diff --git a/ssh-deploy/action.yaml b/ssh-deploy/action.yaml index b01fe29..d907e2d 100644 --- a/ssh-deploy/action.yaml +++ b/ssh-deploy/action.yaml @@ -19,11 +19,25 @@ inputs: description: Directory on the remote server containing docker-compose.yml required: true compose_files: - description: > + description: >- Compose file(s) to use, space-separated (applied via -f flags). When empty, docker compose uses its default file detection. required: false default: "" + image: + description: >- + Optional image base (host/path/name) WITHOUT tag. When set, ssh-deploy + pins every compose service whose image matches this base to `image_tag` + at deploy time. Docker-only feature — k8s deploys use deploy-restart and + are unaffected. Leave empty to deploy whatever the compose file pins. + required: false + default: "" + image_tag: + description: >- + Tag to pin `image` to when `image` is set. Defaults to `latest`. + (Comma-separated lists are NOT supported here — a deploy uses one tag.) + required: false + default: latest runs: using: composite @@ -80,6 +94,8 @@ runs: SSH_USERNAME: ${{ inputs.ssh_username }} REMOTE_DIR: ${{ inputs.remote_dir }} COMPOSE_FILES: ${{ inputs.compose_files }} + OVERRIDE_IMAGE: ${{ inputs.image }} + OVERRIDE_TAG: ${{ inputs.image_tag }} run: | # Build -f flags if compose_files is provided COMPOSE_FLAGS="" @@ -95,10 +111,84 @@ runs: echo "Compose files: ${COMPOSE_FILES}" fi + # Optional tag override (Docker-only). When `image` is set, pin every + # compose service whose image base matches `image` to `image_tag`. + # We generate a remote override file (layered on the compose files via + # -f, later wins) so the deploy pulls/starts exactly the requested tag. + # The override is built on the runner (simple shell) then scp'd to the + # remote, avoiding fragile quoting inside an SSH command. + OVERRIDE_FLAGS="" + if [ -n "${OVERRIDE_IMAGE}" ] && [ "${OVERRIDE_IMAGE// }" != "" ]; then + if [ -z "${OVERRIDE_TAG}" ] || [ "${OVERRIDE_TAG// }" = "" ]; then + echo "::error::image is set but image_tag is empty. Provide image_tag (defaults to latest)." + exit 1 + fi + echo "Pinning image ${OVERRIDE_IMAGE} to tag :${OVERRIDE_TAG}" + + # 1) Fetch the merged remote compose config to the runner. + ssh -i ~/.ssh/id_deploy \ + -p "${SSH_PORT}" \ + -o StrictHostKeyChecking=yes \ + "${SSH_USERNAME}@${SSH_HOST}" \ + "cd '${REMOTE_DIR}' && docker compose ${COMPOSE_FLAGS} config" \ + > /tmp/ssh-deploy-remote.yml + + # 2) Emit a minimal override: only matching services, retagged. + awk -v img="${OVERRIDE_IMAGE}" -v tag="${OVERRIDE_TAG}" ' + /^services:/ { s=1; next } + s && /^ [A-Za-z0-9_-]+:/ { svc=$1; sub(/:$/, "", svc); next } + s && /^ image:/ { + val=$0; sub(/^ image:[ \t]*/, "", val) + last_slash = 0 + for (i = length(val); i > 0; i--) { + if (substr(val, i, 1) == "/") { + last_slash = i + break + } + } + last_colon = 0 + for (i = length(val); i > 0; i--) { + if (substr(val, i, 1) == ":") { + last_colon = i + break + } + } + if (last_colon > last_slash) { + b = substr(val, 1, last_colon - 1) + } else { + b = val + } + if (b == img) { + out = out sprintf(" %s:\n image: %s:%s\n", svc, img, tag) + count++ + } + } + END { + if (count > 0) { + print "services:" + printf "%s", out + } + } + ' /tmp/ssh-deploy-remote.yml > /tmp/ssh-deploy-override.yml + + if [ ! -s /tmp/ssh-deploy-override.yml ]; then + echo "::warning::No compose service uses image base '${OVERRIDE_IMAGE}'. Deploying without override." + else + # 3) Push the override to /tmp on the remote (not into remote_dir, + # so future tag-less deploys don't inherit a stale pinned tag). + scp -i ~/.ssh/id_deploy \ + -P "${SSH_PORT}" \ + -o StrictHostKeyChecking=yes \ + /tmp/ssh-deploy-override.yml \ + "${SSH_USERNAME}@${SSH_HOST}:/tmp/.ssh-deploy-override.yml" + OVERRIDE_FLAGS="-f /tmp/.ssh-deploy-override.yml" + fi + fi + ssh -i ~/.ssh/id_deploy \ -p "${SSH_PORT}" \ -o StrictHostKeyChecking=yes \ "${SSH_USERNAME}@${SSH_HOST}" \ "cd '${REMOTE_DIR}' \ - && docker compose ${COMPOSE_FLAGS} pull \ - && docker compose ${COMPOSE_FLAGS} up -d --force-recreate" + && docker compose ${COMPOSE_FLAGS} ${OVERRIDE_FLAGS} pull \ + && docker compose ${COMPOSE_FLAGS} ${OVERRIDE_FLAGS} up -d --force-recreate"