Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/actions/bump-manifest-image/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Bump manifest image

Opens a pull request that bumps a container's image tag in a Kubernetes
deployment manifest, for a single environment. Domain-agnostic — the caller
supplies the manifest path, container name, image repo, and tag. It is
semver-aware (never downgrades), skips pre-releases for `prod`, and can enable
auto-merge on the PR it opens. Call it from a matrix over environments to fan a
single release out to staging and prod.

## Inputs

<!-- AUTO-DOC-INPUT:START - Do not remove or modify this section -->

| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
|----------------|--------|----------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| auto-merge | string | false | `"false"` | Enable GitHub auto-merge (squash) on the <br>opened PR. |
| base-branch | string | false | `"main"` | Base branch for the PR. |
| container-name | string | true | | Name of the container whose image <br>tag is tracked. |
| environment | string | true | | Target environment. "prod" skips pre-release tags. |
| image-repo | string | true | | Image repo without tag (e.g. ghcr.io/loft-sh/revops-events-api). |
| manifest-path | string | true | | Path to the deployment manifest to <br>edit. |
| tag | string | true | | Release tag to roll out (e.g. v0.2.0 or 0.2.0-rc1). |
| token | string | true | | PAT used to open the PR <br>and (if enabled) enable auto-merge. Must be <br>a PAT or App token, not <br>GITHUB_TOKEN: a GITHUB_TOKEN merge emits no <br>events, so downstream merge-triggered workflows would <br>never run. |

<!-- AUTO-DOC-INPUT:END -->

## Outputs

<!-- AUTO-DOC-OUTPUT:START - Do not remove or modify this section -->

| OUTPUT | TYPE | DESCRIPTION |
|---------------------|--------|----------------------------------------------------------------------|
| pull-request-number | string | Number of the opened PR (empty when no update). |
| updated | string | true when a PR was opened <br>(a newer applicable version existed). |

<!-- AUTO-DOC-OUTPUT:END -->

## Usage

```yaml
on:
repository_dispatch:
types: [update-my-app-version]
workflow_dispatch:
inputs:
tag:
description: 'Image tag to roll out (e.g. v0.2.0)'
required: true

jobs:
bump:
runs-on: ubuntu-latest
strategy:
matrix:
environment: [staging, prod]
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: loft-sh/github-actions/.github/actions/bump-manifest-image@bump-manifest-image/v1
with:
tag: ${{ github.event.client_payload.tag || inputs.tag }}
environment: ${{ matrix.environment }}
manifest-path: kubernetes/manifests/${{ matrix.environment }}/my-app/deployment.yaml
container-name: my-app
image-repo: ghcr.io/loft-sh/my-app
auto-merge: ${{ matrix.environment == 'staging' }}
token: ${{ secrets.GH_ACCESS_TOKEN }}
```

### Auth

`token` must be a Personal Access Token or GitHub App token (not
`GITHUB_TOKEN`). It opens the PR and, when `auto-merge` is enabled, performs
the merge. A merge performed with `GITHUB_TOKEN` emits no events, so any
downstream merge-triggered workflow (for example a deploy notification) would
never run.

### Behaviour

- **Semver-aware** — the PR is only opened when `tag` is strictly newer than
the tag currently in the manifest. Equal or older versions are a no-op.
- **Prod safety** — pre-release tags (anything that is not a bare `X.Y.Z`) are
skipped when `environment` is `prod`.
- **PR shape** — title and branch are derived from the image repo's last path
segment, e.g. `chore(my-app): bump staging to v0.2.0` on branch
`update-staging-my-app-0.2.0`.

## Testing

```bash
make test-bump-manifest-image
```

Runs the bats suites in `test/` against the `src/` scripts. `yq` must be on
`PATH` (pre-installed on GitHub hosted runners).
112 changes: 112 additions & 0 deletions .github/actions/bump-manifest-image/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Bump manifest image
description: |
Opens a pull request that bumps a container's image tag in a Kubernetes
deployment manifest, for a single environment. Domain-agnostic: the caller
supplies the manifest path, container name, image repo, and tag. Skips
pre-releases for prod, never downgrades (semver-aware), and optionally
enables auto-merge. Designed to be called from a matrix over environments.
inputs:
tag:
description: 'Release tag to roll out (e.g. v0.2.0 or 0.2.0-rc1).'
required: true
environment:
description: 'Target environment. "prod" skips pre-release tags.'
required: true
manifest-path:
description: 'Path to the deployment manifest to edit.'
required: true
container-name:
description: 'Name of the container whose image tag is tracked.'
required: true
image-repo:
description: 'Image repo without tag (e.g. ghcr.io/loft-sh/revops-events-api).'
required: true
auto-merge:
description: 'Enable GitHub auto-merge (squash) on the opened PR.'
required: false
default: 'false'
base-branch:
description: 'Base branch for the PR.'
required: false
default: 'main'
token:
description: |
PAT used to open the PR and (if enabled) enable auto-merge. Must be a PAT
or App token, not GITHUB_TOKEN: a GITHUB_TOKEN merge emits no events, so
downstream merge-triggered workflows would never run.
required: true
outputs:
updated:
description: 'true when a PR was opened (a newer applicable version existed).'
value: ${{ steps.decide.outputs.should_update }}
pull-request-number:
description: 'Number of the opened PR (empty when no update).'
value: ${{ steps.pr.outputs.pull-request-number }}
runs:
using: composite
steps:
- name: Resolve version
id: resolve
shell: bash
env:
RAW_TAG: ${{ inputs.tag }}
IMAGE_REPO: ${{ inputs.image-repo }}
run: ${{ github.action_path }}/src/resolve-version.sh

- name: Decide whether to update
id: decide
shell: bash
env:
MANIFEST_PATH: ${{ inputs.manifest-path }}
CONTAINER_NAME: ${{ inputs.container-name }}
NEW_VERSION: ${{ steps.resolve.outputs.new_version }}
IS_STABLE: ${{ steps.resolve.outputs.is_stable }}
ENVIRONMENT: ${{ inputs.environment }}
run: ${{ github.action_path }}/src/should-update.sh

- name: Apply image bump
if: steps.decide.outputs.should_update == 'true'
shell: bash
env:
MANIFEST_PATH: ${{ inputs.manifest-path }}
CONTAINER_NAME: ${{ inputs.container-name }}
IMAGE_REPO: ${{ inputs.image-repo }}
NEW_TAG: ${{ steps.resolve.outputs.new_tag }}
run: ${{ github.action_path }}/src/apply-bump.sh

- name: Create pull request
id: pr
if: steps.decide.outputs.should_update == 'true'
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ inputs.token }}
base: ${{ inputs.base-branch }}
committer: Loft Bot <loft-bot@users.noreply.github.com>
commit-message: "chore(${{ steps.resolve.outputs.app_name }}): bump ${{ inputs.environment }} to ${{ steps.resolve.outputs.new_tag }}"
title: "chore(${{ steps.resolve.outputs.app_name }}): bump ${{ inputs.environment }} to ${{ steps.resolve.outputs.new_tag }}"
body: |
Automated image bump.

- Environment: `${{ inputs.environment }}`
- Image: `${{ inputs.image-repo }}:${{ steps.resolve.outputs.new_tag }}`
- Manifest: `${{ inputs.manifest-path }}`

Opened by the `bump-manifest-image` action.
branch: "update-${{ inputs.environment }}-${{ steps.resolve.outputs.app_name }}-${{ steps.resolve.outputs.new_version }}"
delete-branch: true

- name: Enable auto-merge
if: steps.decide.outputs.should_update == 'true' && inputs.auto-merge == 'true' && steps.pr.outputs.pull-request-number
shell: bash
env:
# PAT, not GITHUB_TOKEN: the merge event must trigger downstream workflows.
GH_TOKEN: ${{ inputs.token }}
PR_NUMBER: ${{ steps.pr.outputs.pull-request-number }}
run: gh pr merge "$PR_NUMBER" --squash --auto

- name: Summary
shell: bash
env:
ENVIRONMENT: ${{ inputs.environment }}
REASON: ${{ steps.decide.outputs.reason }}
run: echo "$ENVIRONMENT - $REASON"
23 changes: 23 additions & 0 deletions .github/actions/bump-manifest-image/src/apply-bump.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Rewrites the tracked container's image tag in the manifest, in place.
#
# Inputs (env):
# MANIFEST_PATH Path to the deployment manifest.
# CONTAINER_NAME Container whose image is rewritten.
# IMAGE_REPO Image repo without tag (e.g. ghcr.io/loft-sh/app).
# NEW_TAG Tag to pin (leading v preserved).
set -euo pipefail

: "${MANIFEST_PATH:?MANIFEST_PATH is required}"
: "${CONTAINER_NAME:?CONTAINER_NAME is required}"
: "${IMAGE_REPO:?IMAGE_REPO is required}"
: "${NEW_TAG:?NEW_TAG is required}"

export NEW_IMAGE="${IMAGE_REPO}:${NEW_TAG}"
export CN="$CONTAINER_NAME"

yq eval \
'(.spec.template.spec.containers[] | select(.name == env(CN)) | .image) = env(NEW_IMAGE)' \
-i "$MANIFEST_PATH"

echo "set ${CONTAINER_NAME} image to ${NEW_IMAGE} in ${MANIFEST_PATH}"
33 changes: 33 additions & 0 deletions .github/actions/bump-manifest-image/src/resolve-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Normalises a release tag and derives the bits the downstream steps need.
#
# Inputs (env):
# RAW_TAG Release tag as received (e.g. v0.2.0 or 0.2.0-rc1).
# IMAGE_REPO Full image repo (e.g. ghcr.io/loft-sh/revops-events-api).
# Outputs ($GITHUB_OUTPUT):
# new_tag The tag verbatim, leading v preserved (image tags keep it).
# new_version Tag with any leading v stripped (for semver comparison).
# is_stable true when new_version is a bare X.Y.Z (no pre-release suffix).
# app_name Last path segment of IMAGE_REPO (used in PR title/branch).
set -euo pipefail

: "${RAW_TAG:?RAW_TAG is required}"
: "${IMAGE_REPO:?IMAGE_REPO is required}"

new_tag="$RAW_TAG"
new_version="${new_tag#v}"

if [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
is_stable=true
else
is_stable=false
fi

app_name="${IMAGE_REPO##*/}"

{
echo "new_tag=${new_tag}"
echo "new_version=${new_version}"
echo "is_stable=${is_stable}"
echo "app_name=${app_name}"
} >> "$GITHUB_OUTPUT"
64 changes: 64 additions & 0 deletions .github/actions/bump-manifest-image/src/should-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Decides whether the target manifest should be bumped to NEW_VERSION.
#
# Inputs (env):
# MANIFEST_PATH Path to the deployment manifest.
# CONTAINER_NAME Container whose image tag is tracked.
# NEW_VERSION Incoming version, v-stripped (e.g. 0.2.0).
# IS_STABLE true|false — whether NEW_VERSION is a stable release.
# ENVIRONMENT Target environment (prod skips pre-releases).
# Outputs ($GITHUB_OUTPUT):
# should_update true when a newer, applicable version warrants a PR.
# current_tag Existing image tag found in the manifest (empty if none).
# reason Human-readable explanation for the decision.
set -euo pipefail

: "${MANIFEST_PATH:?MANIFEST_PATH is required}"
: "${CONTAINER_NAME:?CONTAINER_NAME is required}"
: "${NEW_VERSION:?NEW_VERSION is required}"
: "${IS_STABLE:?IS_STABLE is required}"
: "${ENVIRONMENT:?ENVIRONMENT is required}"

emit() {
{
echo "should_update=$1"
echo "current_tag=${2:-}"
echo "reason=$3"
} >> "$GITHUB_OUTPUT"
echo "$3"
}

if [ ! -f "$MANIFEST_PATH" ]; then
emit false "" "manifest not found at ${MANIFEST_PATH}"
exit 0
fi

# Prod never tracks pre-releases.
if [ "$ENVIRONMENT" = "prod" ] && [ "$IS_STABLE" != "true" ]; then
emit false "" "pre-release skipped for prod"
exit 0
fi

current_image=$(CN="$CONTAINER_NAME" yq eval \
'.spec.template.spec.containers[] | select(.name == env(CN)) | .image' \
"$MANIFEST_PATH")
if [ -z "$current_image" ] || [ "$current_image" = "null" ]; then
emit false "" "container ${CONTAINER_NAME} not found in ${MANIFEST_PATH}"
exit 0
fi

current_tag="${current_image##*:}"
current_version="${current_tag#v}"

# Greater-than test via version sort. Equal returns false (no churn).
verlt() {
[ "$1" != "$2" ] && [ "$1" = "$(printf '%s\n%s' "$1" "$2" | sort -V | head -n1)" ]
}

if [ "$NEW_VERSION" = "$current_version" ]; then
emit false "$current_tag" "already at ${current_tag}"
elif verlt "$current_version" "$NEW_VERSION"; then
emit true "$current_tag" "newer than ${current_tag}, will bump"
else
emit false "$current_tag" "${NEW_VERSION} is not newer than ${current_tag}"
fi
35 changes: 35 additions & 0 deletions .github/actions/bump-manifest-image/test/apply-bump.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bats
# Coverage for apply-bump.sh: rewrites only the target container's tag.

SCRIPT="$BATS_TEST_DIRNAME/../src/apply-bump.sh"
FIXTURE="$BATS_TEST_DIRNAME/fixtures/deployment.yaml"

setup() { MANIFEST="$(mktemp)"; cp "$FIXTURE" "$MANIFEST"; }
teardown() { rm -f "$MANIFEST"; }

image_of() {
CN="$1" yq eval '.spec.template.spec.containers[] | select(.name == env(CN)) | .image' "$MANIFEST"
}

@test "rewrites the tracked container image" {
run env \
MANIFEST_PATH="$MANIFEST" CONTAINER_NAME=revops-events-api \
IMAGE_REPO=ghcr.io/loft-sh/revops-events-api NEW_TAG=v0.2.0 "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(image_of revops-events-api)" = "ghcr.io/loft-sh/revops-events-api:v0.2.0" ]
}

@test "leaves other containers untouched" {
run env \
MANIFEST_PATH="$MANIFEST" CONTAINER_NAME=revops-events-api \
IMAGE_REPO=ghcr.io/loft-sh/revops-events-api NEW_TAG=v0.2.0 "$SCRIPT"
[ "$status" -eq 0 ]
[ "$(image_of sidecar)" = "ghcr.io/loft-sh/sidecar:v9.9.9" ]
}

@test "missing NEW_TAG fails" {
run env \
MANIFEST_PATH="$MANIFEST" CONTAINER_NAME=revops-events-api \
IMAGE_REPO=ghcr.io/loft-sh/revops-events-api "$SCRIPT"
[ "$status" -ne 0 ]
}
12 changes: 12 additions & 0 deletions .github/actions/bump-manifest-image/test/fixtures/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: revops-events-api
spec:
template:
spec:
containers:
- name: revops-events-api
image: ghcr.io/loft-sh/revops-events-api:v0.1.0
- name: sidecar
image: ghcr.io/loft-sh/sidecar:v9.9.9
Loading
Loading