From 1deca16949d6edf6ca22fce0a990ace876a18ead Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Wed, 22 Apr 2026 19:21:58 +0200 Subject: [PATCH 1/5] refactor: extract inline bash into testable scripts with CI pipeline Extract all inline bash logic from action.yml into separate script files under scripts/, add 64 bats-core tests, shellcheck linting, and a CI workflow. Replace curl-based GitHub Deployment creation with actions/github-script using Octokit for type safety and automatic retry/rate-limiting. Key changes: - Extract tag generation, architecture verification, image retagging, and GitOps update logic into individual scripts - Add shared utility library (logging, retry with backoff, validation) - Deduplicate 4x identical file-update loops into process_file_updates() - Replace manual curl+jq deployment creation with actions/github-script - Improve git push retry: exponential backoff (5 attempts) instead of simple chaining (3 attempts) - Fix shellcheck warnings (SC2206: array=($line) -> read -r) - Add CI pipeline: shellcheck, bats tests, action structure validation All inputs (29), outputs (3), and step IDs remain unchanged. Consumers do not need to modify their workflows. Co-Authored-By: Claude --- .github/workflows/ci.yml | 62 ++++++ .gitignore | 12 + .shellcheckrc | 2 + action.yml | 381 +++++++++----------------------- scripts/generate-tags.sh | 62 ++++++ scripts/lib/common.sh | 75 +++++++ scripts/lib/gitops-functions.sh | 86 +++++++ scripts/retag-image.sh | 87 ++++++++ scripts/update-gitops.sh | 51 +++++ scripts/verify-architecture.sh | 31 +++ tests/fixtures/sample-app.yaml | 12 + tests/generate-tags.bats | 171 ++++++++++++++ tests/lib-common.bats | 126 +++++++++++ tests/lib-gitops-functions.bats | 181 +++++++++++++++ tests/retag-image.bats | 91 ++++++++ tests/test_helper/setup.bash | 50 +++++ tests/update-gitops.bats | 142 ++++++++++++ tests/verify-architecture.bats | 75 +++++++ 18 files changed, 1425 insertions(+), 272 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .shellcheckrc create mode 100755 scripts/generate-tags.sh create mode 100755 scripts/lib/common.sh create mode 100755 scripts/lib/gitops-functions.sh create mode 100755 scripts/retag-image.sh create mode 100755 scripts/update-gitops.sh create mode 100755 scripts/verify-architecture.sh create mode 100644 tests/fixtures/sample-app.yaml create mode 100644 tests/generate-tags.bats create mode 100644 tests/lib-common.bats create mode 100644 tests/lib-gitops-functions.bats create mode 100644 tests/retag-image.bats create mode 100644 tests/test_helper/setup.bash create mode 100644 tests/update-gitops.bats create mode 100644 tests/verify-architecture.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e783a0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + shellcheck: + name: Shellcheck + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run shellcheck on all scripts + run: find scripts/ -name '*.sh' -exec shellcheck --severity=warning {} + + + test: + name: Bash Tests + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install bats and helpers + run: | + sudo npm install -g bats + git clone --depth 1 https://github.com/bats-core/bats-support.git tests/test_helper/bats-support + git clone --depth 1 https://github.com/bats-core/bats-assert.git tests/test_helper/bats-assert + + - name: Run tests + run: bats tests/*.bats + + validate-action: + name: Validate Action Structure + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify all referenced scripts exist + run: | + grep -oP 'github\.action_path \}\}/\K[^ "]+' action.yml | while read -r script; do + if [[ ! -f "$script" ]]; then + echo "::error::Script referenced in action.yml does not exist: $script" + exit 1 + fi + done + + - name: Verify scripts are executable + run: | + find scripts/ -name '*.sh' | while read -r script; do + if [[ ! -x "$script" ]]; then + echo "::error::Script is not executable: $script" + exit 1 + fi + done diff --git a/.gitignore b/.gitignore index 23c4077..853f259 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,14 @@ # Intellij .idea/* + +# Node +node_modules/ +package.json +package-lock.json + +# Test helper libraries (installed during CI/local setup) +tests/test_helper/bats-support/ +tests/test_helper/bats-assert/ + +# Test artifacts +headers.txt diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..b14745b --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +shell=bash +external-sources=true diff --git a/action.yml b/action.yml index 31dcc00..8eb216d 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ name: 'Docker and GitOps Commit' description: 'Build and push the Docker image and commits the new version to your GitOps repo.' -author: 'Staffbase GmbH' +author: 'Staffbase SE' inputs: docker-registry: @@ -112,7 +112,7 @@ outputs: value: ${{ steps.docker_build.outputs.digest || steps.docker_retag.outputs.digest }} deployment-id: description: 'JSON map of environment to GitHub Deployment ID (only set when create-deployment is true)' - value: ${{ steps.update_image.outputs.deployment_ids }} + value: ${{ steps.create_deployments.outputs.deployment_ids || steps.update_image.outputs.deployment_ids }} runs: using: "composite" @@ -120,78 +120,20 @@ runs: - name: Generate Tags id: preparation shell: bash - run: | - BUILD="true" - if [[ -n "${{ inputs.docker-custom-tag }}" ]]; then - TAG="${{ inputs.docker-custom-tag }}" - LATEST="latest" - PUSH="true" - BUILD="${{ inputs.docker-disable-retagging }}" - elif [[ $GITHUB_REF == refs/heads/master ]]; then - TAG="master-${GITHUB_SHA::8}" - LATEST="master" - PUSH="true" - elif [[ $GITHUB_REF == refs/heads/main ]]; then - TAG="main-${GITHUB_SHA::8}" - LATEST="main" - PUSH="true" - elif [[ $GITHUB_REF == refs/heads/dev ]]; then - TAG="dev-${GITHUB_SHA::8}" - LATEST="dev" - PUSH="true" - elif [[ $GITHUB_REF == refs/tags/v* ]]; then - TAG="${GITHUB_REF:11}" - LATEST="latest" - PUSH="true" - BUILD="${{ inputs.docker-disable-retagging }}" - elif [[ $GITHUB_REF == refs/tags/* ]]; then - TAG="${GITHUB_REF:10}" - LATEST="latest" - PUSH="true" - BUILD="${{ inputs.docker-disable-retagging }}" - else - TAG="${GITHUB_SHA::8}" - PUSH="false" - fi - - TAG_LIST="${{ inputs.docker-registry }}/${{ inputs.docker-image }}:${TAG}" - if [[ ! -z "${LATEST}" ]]; then - TAG_LIST+=",${{ inputs.docker-registry }}/${{ inputs.docker-image }}:${LATEST}" - fi - - echo "build=$BUILD" >> $GITHUB_OUTPUT - echo "latest=$LATEST" >> $GITHUB_OUTPUT - echo "push=$PUSH" >> $GITHUB_OUTPUT - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "tag_list=$TAG_LIST" >> $GITHUB_OUTPUT + env: + INPUT_DOCKER_CUSTOM_TAG: ${{ inputs.docker-custom-tag }} + INPUT_DOCKER_DISABLE_RETAGGING: ${{ inputs.docker-disable-retagging }} + INPUT_DOCKER_REGISTRY: ${{ inputs.docker-registry }} + INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} + run: ${{ github.action_path }}/scripts/generate-tags.sh - name: Verify Architecture Match shell: bash if: steps.preparation.outputs.build == 'true' - run: | - RUNNER_ARCH="${{ runner.arch }}" # X64 (AMD64) or ARM64 - TARGET_PLATFORMS="${{ inputs.docker-build-platforms }}" - - echo "Runner CPU Architecture: $RUNNER_ARCH" - echo "Requested Build Platforms: $TARGET_PLATFORMS" - - # Check for AMD64 mismatch (Runner is X64, but user requests ONLY arm64, OR user requests multi-arch which requires emulation) - if [[ "$RUNNER_ARCH" == "X64" ]]; then - if [[ "$TARGET_PLATFORMS" == *"linux/arm64"* ]]; then - echo "::error::Runner is X64 (Intel/AMD) but build includes 'linux/arm64'. This requires emulation. Aborting strictly." - exit 1 - fi - fi - - # Check for ARM64 mismatch - if [[ "$RUNNER_ARCH" == "ARM64" ]]; then - if [[ "$TARGET_PLATFORMS" == *"linux/amd64"* ]]; then - echo "::error::Runner is ARM64 (Apple Silicon/Graviton) but build includes 'linux/amd64'. This requires emulation. Aborting strictly." - exit 1 - fi - fi - - echo "Architecture match verified for native build ✅" + env: + RUNNER_ARCH: ${{ runner.arch }} + INPUT_DOCKER_BUILD_PLATFORMS: ${{ inputs.docker-build-platforms }} + run: ${{ github.action_path }}/scripts/verify-architecture.sh - name: Set up Docker Buildx if: inputs.docker-username != '' && inputs.docker-password != '' @@ -205,7 +147,6 @@ runs: username: ${{ inputs.docker-username }} password: ${{ inputs.docker-password }} - - name: Build id: docker_build if: steps.preparation.outputs.build == 'true' && inputs.docker-username != '' && inputs.docker-password != '' @@ -229,60 +170,14 @@ runs: id: docker_retag if: steps.preparation.outputs.build == 'false' shell: bash - run: | - CHECK_EXISTING_TAGS="master-${GITHUB_SHA::8} main-${GITHUB_SHA::8}" - # Accept both single-arch manifests and multi-arch manifest lists/indexes - ACCEPT_HEADER="application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json" - - echo "CHECK_EXISTING_TAGS: ${CHECK_EXISTING_TAGS}" - echo "RELEASE_TAG: ${RELEASE_TAG:1}" - echo "Check if an image already exists for ${{ inputs.docker-image }}:main|master-${GITHUB_SHA::8} 🐋 ⬇" - - foundImage=false - DETECTED_CONTENT_TYPE="" - DIGEST="" - - end=$((SECONDS+300)) - while [ $SECONDS -lt $end ]; do - - MANIFEST="" - for tag in $CHECK_EXISTING_TAGS; do - # Dump headers to file to extract Content-Type and Digest later - MANIFEST=$(curl -s -D headers.txt -H "Accept: ${ACCEPT_HEADER}" -u '${{ inputs.docker-username }}:${{ inputs.docker-password }}' "${{ inputs.docker-registry-api }}${{ inputs.docker-image}}/manifests/${tag}") - - if [[ $MANIFEST == *"errors"* ]]; then - echo "No image found for ${{ inputs.docker-image }}:${tag} 🚫" - continue - else - echo "Image found for ${{ inputs.docker-image }}:${tag} 🐋 ⬇" - foundImage=true - - # Extract the Content-Type returned by registry - DETECTED_CONTENT_TYPE=$(grep -i "^Content-Type:" headers.txt | cut -d' ' -f2 | tr -d '\r') - - # Extract the correct digest from headers (works for lists and single images) - DIGEST=$(grep -i "^Docker-Content-Digest:" headers.txt | cut -d' ' -f2 | tr -d '\r') - - break 2 - fi - done - - sleep 10 - done - - if [[ $foundImage == false ]]; then - echo "No image found for ${{ inputs.docker-image }}:main|master-${GITHUB_SHA::8} 🚫 within 300 seconds" - exit 1 - fi - - echo "Retagging image with release version and :latest tags for ${{ inputs.docker-image }} 🏷" - echo "Using Content-Type: ${DETECTED_CONTENT_TYPE}" - - # Use the detected Content-Type to PUT the manifest back - curl --fail-with-body -X PUT -H "Content-Type: ${DETECTED_CONTENT_TYPE}" -u '${{ inputs.docker-username }}:${{ inputs.docker-password }}' -d "${MANIFEST}" "${{ inputs.docker-registry-api }}${{ inputs.docker-image}}/manifests/${{ steps.preparation.outputs.tag }}" - curl --fail-with-body -X PUT -H "Content-Type: ${DETECTED_CONTENT_TYPE}" -u '${{ inputs.docker-username }}:${{ inputs.docker-password }}' -d "${MANIFEST}" "${{ inputs.docker-registry-api }}${{ inputs.docker-image}}/manifests/${{ steps.preparation.outputs.latest }}" - - echo "digest=$DIGEST" >> $GITHUB_OUTPUT + env: + INPUT_DOCKER_USERNAME: ${{ inputs.docker-username }} + INPUT_DOCKER_PASSWORD: ${{ inputs.docker-password }} + INPUT_DOCKER_REGISTRY_API: ${{ inputs.docker-registry-api }} + INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} + INPUT_TAG: ${{ steps.preparation.outputs.tag }} + INPUT_LATEST: ${{ steps.preparation.outputs.latest }} + run: ${{ github.action_path }}/scripts/retag-image.sh - name: Checkout GitOps Repository if: inputs.gitops-token != '' @@ -292,158 +187,100 @@ runs: token: ${{ inputs.gitops-token }} path: .github/${{ inputs.gitops-repository }} + - name: Create GitHub Deployments + id: create_deployments + if: inputs.create-deployment == 'true' && inputs.github-token != '' && inputs.gitops-token != '' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + INPUT_GITOPS_DEV: ${{ inputs.gitops-dev }} + INPUT_GITOPS_STAGE: ${{ inputs.gitops-stage }} + INPUT_GITOPS_PROD: ${{ inputs.gitops-prod }} + DOCKER_IMAGE: ${{ inputs.docker-registry }}/${{ inputs.docker-image }} + DOCKER_TAG: ${{ steps.preparation.outputs.tag }} + with: + github-token: ${{ inputs.github-token }} + script: | + const ref = process.env.GITHUB_REF; + const image = process.env.DOCKER_IMAGE; + const tag = process.env.DOCKER_TAG; + + let fileList = ''; + if ((ref === 'refs/heads/master' || ref === 'refs/heads/main') && process.env.INPUT_GITOPS_STAGE) { + fileList = process.env.INPUT_GITOPS_STAGE; + } else if (ref === 'refs/heads/dev' && process.env.INPUT_GITOPS_DEV) { + fileList = process.env.INPUT_GITOPS_DEV; + } else if (ref.startsWith('refs/tags/') && process.env.INPUT_GITOPS_PROD) { + fileList = process.env.INPUT_GITOPS_PROD; + } + + if (!fileList) { + core.setOutput('deployment_ids', '{}'); + return; + } + + // Derive unique environments from file paths + // Path format: kubernetes/namespaces////.yaml + const environments = [...new Set( + fileList.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .map(line => { + const filePath = line.split(/\s+/)[0]; + const parts = filePath.split('/'); + return `${parts[3]}-${parts[4]}`; + }) + )]; + + const ids = {}; + for (const env of environments) { + try { + const { data: deployment } = await github.rest.repos.createDeployment({ + ...context.repo, + ref: context.sha, + environment: env, + auto_merge: false, + required_contexts: [], + payload: { image, tag }, + description: `Deploy ${image}:${tag} to ${env}` + }); + + await github.rest.repos.createDeploymentStatus({ + ...context.repo, + deployment_id: deployment.id, + state: 'in_progress', + description: 'Updating GitOps repository' + }); + + core.info(`Created deployment ${deployment.id} for environment ${env}`); + ids[env] = String(deployment.id); + } catch (error) { + core.warning(`Failed to create GitHub Deployment for ${env}: ${error.message}`); + } + } + + core.setOutput('deployment_ids', JSON.stringify(ids)); + - name: Update Docker Image in Repository id: update_image if: inputs.gitops-token != '' working-directory: .github/${{ inputs.gitops-repository }} shell: bash - run: | - IMAGE="${{ inputs.docker-registry }}/${{ inputs.docker-image }}:${{ steps.preparation.outputs.tag }}" - CREATE_DEPLOYMENT="${{ inputs.create-deployment }}" - GITHUB_TOKEN_INPUT="${{ inputs.github-token }}" - DEPLOYMENT_IDS='{}' - - push_to_gitops_repo () { - # In case there was another push in the meantime, we pull it again - git pull --rebase https://${{ inputs.gitops-user }}:${{ inputs.gitops-token }}@github.com/${{ inputs.gitops-organization }}/${{ inputs.gitops-repository }}.git - git push https://${{ inputs.gitops-user }}:${{ inputs.gitops-token }}@github.com/${{ inputs.gitops-organization }}/${{ inputs.gitops-repository }}.git - } - - commit_changes () { - if [[ ${{ steps.preparation.outputs.push }} == "true" ]]; then - git add . - - # commit with no errors if there are no changes - if git diff-index --quiet HEAD; then - echo "There were no changes..." - return - fi - - git commit -m "Release ${{ inputs.docker-registry }}/${{ inputs.docker-image }}:${{ steps.preparation.outputs.tag }}" - - # retry push attempt since rejections can still happen (even with pull before push) - push_to_gitops_repo || push_to_gitops_repo || push_to_gitops_repo - fi - } - - derive_environment () { - local file_path="$1" - # Path: kubernetes/namespaces////.yaml - local env=$(echo "$file_path" | cut -d'/' -f4) - local cluster=$(echo "$file_path" | cut -d'/' -f5) - echo "${env}-${cluster}" - } - - create_deployment () { - local environment="$1" - local image="$2" - local tag="$3" - - RESPONSE=$(curl -s -w "\n%{http_code}" \ - -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${GITHUB_TOKEN_INPUT}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${GITHUB_REPOSITORY}/deployments" \ - -d "{ - \"ref\": \"${GITHUB_SHA}\", - \"environment\": \"${environment}\", - \"auto_merge\": false, - \"required_contexts\": [], - \"payload\": { - \"image\": \"${image}\", - \"tag\": \"${tag}\" - }, - \"description\": \"Deploy ${image}:${tag} to ${environment}\" - }") - - HTTP_CODE=$(echo "$RESPONSE" | tail -1) - BODY=$(echo "$RESPONSE" | sed '$d') - - if [[ "$HTTP_CODE" -lt 200 || "$HTTP_CODE" -ge 300 ]]; then - echo "::warning::Failed to create GitHub Deployment for ${environment} (HTTP ${HTTP_CODE}): ${BODY}" >&2 - return - fi - - local deployment_id=$(echo "$BODY" | jq -r '.id') - echo "Created deployment ${deployment_id} for environment ${environment}" >&2 - - # Set initial status to in_progress - curl -s \ - -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${GITHUB_TOKEN_INPUT}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${GITHUB_REPOSITORY}/deployments/${deployment_id}/statuses" \ - -d "{\"state\": \"in_progress\", \"description\": \"Updating GitOps repository\"}" > /dev/null - - echo "$deployment_id" - } - - update_file () { - local file="$1" - local field="$2" - local image="$3" - - echo "Check if path ${file} ${field} exists and get old current version" - yq -e ."${field}" "${file}" - echo "Run update ${file} ${field} ${image}" - yq -i ."${field}"=\""${image}"\" "${file}" - - if [[ "$CREATE_DEPLOYMENT" == "true" ]]; then - local deploy_env=$(derive_environment "${file}") - local deploy_id="" - - if [[ -n "$GITHUB_TOKEN_INPUT" ]]; then - deploy_id=$(create_deployment "${deploy_env}" "${{ inputs.docker-registry }}/${{ inputs.docker-image }}" "${{ steps.preparation.outputs.tag }}") - fi - - echo "Writing deployment annotations to ${file}" - yq -i '.metadata.annotations["deploy.staffbase.com/repo"] = "'"${GITHUB_REPOSITORY}"'"' "${file}" - yq -i '.metadata.annotations["deploy.staffbase.com/sha"] = "'"${GITHUB_SHA}"'"' "${file}" - if [[ -n "$deploy_id" ]]; then - yq -i '.metadata.annotations["deploy.staffbase.com/deployment-id"] = "'"${deploy_id}"'"' "${file}" - DEPLOYMENT_IDS=$(echo "$DEPLOYMENT_IDS" | jq -c --arg env "$deploy_env" --arg id "$deploy_id" '. + {($env): $id}') - fi - fi - } - - # configure git user - git config --global user.email "${{ inputs.gitops-email }}" && git config --global user.name "${{ inputs.gitops-user }}" - - if [[ ( $GITHUB_REF == refs/heads/master || $GITHUB_REF == refs/heads/main ) && -n "${{ inputs.gitops-stage }}" ]]; then - echo "Run update for STAGE" - while IFS= read -r line; do - array=($line) - update_file "${array[0]}" "${array[1]}" "$IMAGE" - done <<< "${{ inputs.gitops-stage }}" - commit_changes - - elif [[ $GITHUB_REF == refs/heads/dev && -n "${{ inputs.gitops-dev }}" ]]; then - echo "Run update for DEV" - while IFS= read -r line; do - array=($line) - update_file "${array[0]}" "${array[1]}" "$IMAGE" - done <<< "${{ inputs.gitops-dev }}" - commit_changes - - elif [[ $GITHUB_REF == refs/tags/* && -n "${{ inputs.gitops-prod }}" ]]; then - echo "Run update for PROD" - while IFS= read -r line; do - array=($line) - update_file "${array[0]}" "${array[1]}" "$IMAGE" - done <<< "${{ inputs.gitops-prod }}" - commit_changes - - elif [[ -n "${{ inputs.gitops-dev }}" ]]; then - echo "Simulate update for DEV" - while IFS= read -r line; do - array=($line) - update_file "${array[0]}" "${array[1]}" "$IMAGE" - done <<< "${{ inputs.gitops-dev }}" - fi - - echo "deployment_ids=$DEPLOYMENT_IDS" >> $GITHUB_OUTPUT + env: + INPUT_DOCKER_REGISTRY: ${{ inputs.docker-registry }} + INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} + INPUT_TAG: ${{ steps.preparation.outputs.tag }} + INPUT_PUSH: ${{ steps.preparation.outputs.push }} + INPUT_CREATE_DEPLOYMENT: ${{ inputs.create-deployment }} + INPUT_DEPLOYMENT_IDS: ${{ steps.create_deployments.outputs.deployment_ids }} + INPUT_GITOPS_USER: ${{ inputs.gitops-user }} + INPUT_GITOPS_EMAIL: ${{ inputs.gitops-email }} + INPUT_GITOPS_TOKEN: ${{ inputs.gitops-token }} + INPUT_GITOPS_ORGANIZATION: ${{ inputs.gitops-organization }} + INPUT_GITOPS_REPOSITORY: ${{ inputs.gitops-repository }} + INPUT_GITOPS_DEV: ${{ inputs.gitops-dev }} + INPUT_GITOPS_STAGE: ${{ inputs.gitops-stage }} + INPUT_GITOPS_PROD: ${{ inputs.gitops-prod }} + run: ${{ github.action_path }}/scripts/update-gitops.sh - name: Emit Image Build Event to Upwind.io env: diff --git a/scripts/generate-tags.sh b/scripts/generate-tags.sh new file mode 100755 index 0000000..f804548 --- /dev/null +++ b/scripts/generate-tags.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Generates Docker image tags based on the current Git ref. +# +# Required env vars: GITHUB_REF, GITHUB_SHA, INPUT_DOCKER_REGISTRY, INPUT_DOCKER_IMAGE +# Optional env vars: INPUT_DOCKER_CUSTOM_TAG, INPUT_DOCKER_DISABLE_RETAGGING +# +# Outputs (via GITHUB_OUTPUT): build, latest, push, tag, tag_list + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" + +require_env GITHUB_REF +require_env GITHUB_SHA +require_env INPUT_DOCKER_REGISTRY +require_env INPUT_DOCKER_IMAGE + +BUILD="true" + +if [[ -n "${INPUT_DOCKER_CUSTOM_TAG:-}" ]]; then + TAG="${INPUT_DOCKER_CUSTOM_TAG}" + LATEST="latest" + PUSH="true" + BUILD="${INPUT_DOCKER_DISABLE_RETAGGING:-false}" +elif [[ $GITHUB_REF == refs/heads/master ]]; then + TAG="master-${GITHUB_SHA::8}" + LATEST="master" + PUSH="true" +elif [[ $GITHUB_REF == refs/heads/main ]]; then + TAG="main-${GITHUB_SHA::8}" + LATEST="main" + PUSH="true" +elif [[ $GITHUB_REF == refs/heads/dev ]]; then + TAG="dev-${GITHUB_SHA::8}" + LATEST="dev" + PUSH="true" +elif [[ $GITHUB_REF == refs/tags/v* ]]; then + TAG="${GITHUB_REF:11}" + LATEST="latest" + PUSH="true" + BUILD="${INPUT_DOCKER_DISABLE_RETAGGING:-false}" +elif [[ $GITHUB_REF == refs/tags/* ]]; then + TAG="${GITHUB_REF:10}" + LATEST="latest" + PUSH="true" + BUILD="${INPUT_DOCKER_DISABLE_RETAGGING:-false}" +else + TAG="${GITHUB_SHA::8}" + PUSH="false" + LATEST="" +fi + +TAG_LIST="${INPUT_DOCKER_REGISTRY}/${INPUT_DOCKER_IMAGE}:${TAG}" +if [[ -n "${LATEST:-}" ]]; then + TAG_LIST+=",${INPUT_DOCKER_REGISTRY}/${INPUT_DOCKER_IMAGE}:${LATEST}" +fi + +set_output "build" "$BUILD" +set_output "latest" "${LATEST:-}" +set_output "push" "$PUSH" +set_output "tag" "$TAG" +set_output "tag_list" "$TAG_LIST" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100755 index 0000000..f3ac081 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Common utility functions for all scripts in this action. +# Sourced by other scripts — not executed directly. + +set -euo pipefail + +# --- Logging --- + +log_info() { + echo "::notice::$1" +} + +log_warn() { + echo "::warning::$1" +} + +log_error() { + echo "::error::$1" >&2 +} + +# --- Output --- + +set_output() { + local name="$1" + local value="$2" + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "${name}=${value}" >> "$GITHUB_OUTPUT" + else + echo "OUTPUT ${name}=${value}" + fi +} + +# --- Validation --- + +require_env() { + local var_name="$1" + if [[ -z "${!var_name:-}" ]]; then + log_error "Required environment variable '${var_name}' is not set or empty." + exit 1 + fi +} + +require_tool() { + local tool_name="$1" + if ! command -v "$tool_name" &>/dev/null; then + log_error "Required tool '${tool_name}' is not installed or not on PATH." + exit 1 + fi +} + +# --- Retry --- + +retry_with_backoff() { + local max_attempts="$1" + local base_delay="$2" + shift 2 + local attempt=1 + local delay="$base_delay" + + while true; do + if "$@"; then + return 0 + fi + + if (( attempt >= max_attempts )); then + log_error "Command failed after ${max_attempts} attempts: $*" + return 1 + fi + + log_warn "Attempt ${attempt}/${max_attempts} failed. Retrying in ${delay}s..." + sleep "$delay" + attempt=$((attempt + 1)) + delay=$((delay * 2)) + done +} diff --git a/scripts/lib/gitops-functions.sh b/scripts/lib/gitops-functions.sh new file mode 100755 index 0000000..3a2f7a9 --- /dev/null +++ b/scripts/lib/gitops-functions.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# GitOps helper functions for updating Kubernetes manifests. +# Sourced by update-gitops.sh — not executed directly. +# +# Expected env vars (set by caller): +# INPUT_DOCKER_REGISTRY, INPUT_DOCKER_IMAGE, INPUT_TAG, INPUT_PUSH, +# INPUT_CREATE_DEPLOYMENT, INPUT_DEPLOYMENT_IDS, +# INPUT_GITOPS_USER, INPUT_GITOPS_TOKEN, +# INPUT_GITOPS_ORGANIZATION, INPUT_GITOPS_REPOSITORY, +# GITHUB_REPOSITORY, GITHUB_SHA, IMAGE + +push_to_gitops_repo() { + git pull --rebase "https://${INPUT_GITOPS_USER}:${INPUT_GITOPS_TOKEN}@github.com/${INPUT_GITOPS_ORGANIZATION}/${INPUT_GITOPS_REPOSITORY}.git" + git push "https://${INPUT_GITOPS_USER}:${INPUT_GITOPS_TOKEN}@github.com/${INPUT_GITOPS_ORGANIZATION}/${INPUT_GITOPS_REPOSITORY}.git" +} + +commit_changes() { + if [[ "${INPUT_PUSH}" == "true" ]]; then + git add . + + if git diff-index --quiet HEAD; then + echo "There were no changes..." + return + fi + + git commit -m "Release ${INPUT_DOCKER_REGISTRY}/${INPUT_DOCKER_IMAGE}:${INPUT_TAG}" + + retry_with_backoff 5 2 push_to_gitops_repo + fi +} + +# Derives the environment identifier from a mops file path. +# Expected path format: kubernetes/namespaces////.yaml +derive_environment() { + local file_path="$1" + local env cluster + env=$(echo "$file_path" | cut -d'/' -f4) + cluster=$(echo "$file_path" | cut -d'/' -f5) + echo "${env}-${cluster}" +} + +update_file() { + local file="$1" + local field="$2" + local image="$3" + + echo "Check if path ${file} ${field} exists and get old current version" + yq -e ."${field}" "${file}" + echo "Run update ${file} ${field} ${image}" + yq -i ."${field}"=\""${image}"\" "${file}" + + if [[ "${INPUT_CREATE_DEPLOYMENT}" == "true" ]]; then + local deploy_env + deploy_env=$(derive_environment "${file}") + + echo "Writing deployment annotations to ${file}" + yq -i '.metadata.annotations["deploy.staffbase.com/repo"] = "'"${GITHUB_REPOSITORY}"'"' "${file}" + yq -i '.metadata.annotations["deploy.staffbase.com/sha"] = "'"${GITHUB_SHA}"'"' "${file}" + + # Write deployment-id annotation if available from the create_deployments step + local deploy_id="" + if [[ -n "${INPUT_DEPLOYMENT_IDS:-}" && "${INPUT_DEPLOYMENT_IDS}" != "{}" ]]; then + deploy_id=$(echo "${INPUT_DEPLOYMENT_IDS}" | jq -r --arg env "$deploy_env" '.[$env] // empty') + fi + + if [[ -n "$deploy_id" ]]; then + yq -i '.metadata.annotations["deploy.staffbase.com/deployment-id"] = "'"${deploy_id}"'"' "${file}" + fi + fi +} + +process_file_updates() { + local file_list="$1" + local should_commit="$2" + + while IFS= read -r line; do + [[ -z "$line" ]] && continue + local file field + read -r file field <<< "$line" + update_file "$file" "$field" "$IMAGE" + done <<< "$file_list" + + if [[ "$should_commit" == "true" ]]; then + commit_changes + fi +} diff --git a/scripts/retag-image.sh b/scripts/retag-image.sh new file mode 100755 index 0000000..174b6a4 --- /dev/null +++ b/scripts/retag-image.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Retags an existing Docker image in the registry without rebuilding. +# Polls for an existing master-/main- tagged image and retags it with the release tag. +# +# Required env vars: GITHUB_SHA, INPUT_DOCKER_USERNAME, INPUT_DOCKER_PASSWORD, +# INPUT_DOCKER_REGISTRY_API, INPUT_DOCKER_IMAGE, INPUT_TAG, INPUT_LATEST +# Optional env vars: RETAG_TIMEOUT_SECONDS (default: 300), RETAG_POLL_INTERVAL (default: 10) +# +# Outputs (via GITHUB_OUTPUT): digest + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" + +require_env GITHUB_SHA +require_env INPUT_DOCKER_USERNAME +require_env INPUT_DOCKER_PASSWORD +require_env INPUT_DOCKER_REGISTRY_API +require_env INPUT_DOCKER_IMAGE +require_env INPUT_TAG +require_env INPUT_LATEST + +TIMEOUT="${RETAG_TIMEOUT_SECONDS:-300}" +POLL_INTERVAL="${RETAG_POLL_INTERVAL:-10}" + +CHECK_EXISTING_TAGS="master-${GITHUB_SHA::8} main-${GITHUB_SHA::8}" +ACCEPT_HEADER="application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json" + +echo "CHECK_EXISTING_TAGS: ${CHECK_EXISTING_TAGS}" +echo "Check if an image already exists for ${INPUT_DOCKER_IMAGE}:main|master-${GITHUB_SHA::8}" + +retag_manifest() { + local target_tag="$1" + local manifest="$2" + local content_type="$3" + curl --fail-with-body -X PUT \ + -H "Content-Type: ${content_type}" \ + -u "${INPUT_DOCKER_USERNAME}:${INPUT_DOCKER_PASSWORD}" \ + -d "${manifest}" \ + "${INPUT_DOCKER_REGISTRY_API}${INPUT_DOCKER_IMAGE}/manifests/${target_tag}" +} + +foundImage=false +DETECTED_CONTENT_TYPE="" +DIGEST="" +MANIFEST="" + +end=$((SECONDS + TIMEOUT)) +attempt=1 +while [ $SECONDS -lt $end ]; do + remaining=$((end - SECONDS)) + echo "Poll attempt ${attempt} (${remaining}s remaining)..." + + for tag in $CHECK_EXISTING_TAGS; do + MANIFEST=$(curl -s -D headers.txt \ + -H "Accept: ${ACCEPT_HEADER}" \ + -u "${INPUT_DOCKER_USERNAME}:${INPUT_DOCKER_PASSWORD}" \ + "${INPUT_DOCKER_REGISTRY_API}${INPUT_DOCKER_IMAGE}/manifests/${tag}") + + if [[ $MANIFEST == *"errors"* ]]; then + echo "No image found for ${INPUT_DOCKER_IMAGE}:${tag}" + continue + else + echo "Image found for ${INPUT_DOCKER_IMAGE}:${tag}" + foundImage=true + DETECTED_CONTENT_TYPE=$(grep -i "^Content-Type:" headers.txt | cut -d' ' -f2 | tr -d '\r') + DIGEST=$(grep -i "^Docker-Content-Digest:" headers.txt | cut -d' ' -f2 | tr -d '\r') + break 2 + fi + done + + sleep "$POLL_INTERVAL" + attempt=$((attempt + 1)) +done + +if [[ $foundImage == false ]]; then + log_error "No image found for ${INPUT_DOCKER_IMAGE}:main|master-${GITHUB_SHA::8} within ${TIMEOUT} seconds" + exit 1 +fi + +echo "Retagging image with release version and :latest tags for ${INPUT_DOCKER_IMAGE}" +echo "Using Content-Type: ${DETECTED_CONTENT_TYPE}" + +retag_manifest "$INPUT_TAG" "$MANIFEST" "$DETECTED_CONTENT_TYPE" +retag_manifest "$INPUT_LATEST" "$MANIFEST" "$DETECTED_CONTENT_TYPE" + +set_output "digest" "$DIGEST" diff --git a/scripts/update-gitops.sh b/scripts/update-gitops.sh new file mode 100755 index 0000000..bbb85ab --- /dev/null +++ b/scripts/update-gitops.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Updates Docker image references in a GitOps repository. +# Determines which environment (DEV/STAGE/PROD) to update based on the Git ref, +# then updates the corresponding YAML files using yq. +# +# Required env vars: GITHUB_REF, GITHUB_SHA, GITHUB_REPOSITORY, +# INPUT_DOCKER_REGISTRY, INPUT_DOCKER_IMAGE, INPUT_TAG, INPUT_PUSH, +# INPUT_CREATE_DEPLOYMENT, INPUT_GITOPS_USER, INPUT_GITOPS_EMAIL, +# INPUT_GITOPS_TOKEN, INPUT_GITOPS_ORGANIZATION, INPUT_GITOPS_REPOSITORY +# Optional env vars: INPUT_GITOPS_DEV, INPUT_GITOPS_STAGE, INPUT_GITOPS_PROD, +# INPUT_DEPLOYMENT_IDS + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" +# shellcheck source=lib/gitops-functions.sh +source "${SCRIPT_DIR}/lib/gitops-functions.sh" + +require_env GITHUB_REF +require_env INPUT_DOCKER_REGISTRY +require_env INPUT_DOCKER_IMAGE +require_env INPUT_TAG +require_env INPUT_GITOPS_USER +require_env INPUT_GITOPS_EMAIL +require_env INPUT_GITOPS_TOKEN +require_env INPUT_GITOPS_ORGANIZATION +require_env INPUT_GITOPS_REPOSITORY + +# Used by gitops-functions.sh (process_file_updates -> update_file) +# shellcheck disable=SC2034 +IMAGE="${INPUT_DOCKER_REGISTRY}/${INPUT_DOCKER_IMAGE}:${INPUT_TAG}" + +# Configure git user +git config --global user.email "${INPUT_GITOPS_EMAIL}" && git config --global user.name "${INPUT_GITOPS_USER}" + +if [[ ( $GITHUB_REF == refs/heads/master || $GITHUB_REF == refs/heads/main ) && -n "${INPUT_GITOPS_STAGE:-}" ]]; then + log_info "Run update for STAGE" + process_file_updates "$INPUT_GITOPS_STAGE" "true" + +elif [[ $GITHUB_REF == refs/heads/dev && -n "${INPUT_GITOPS_DEV:-}" ]]; then + log_info "Run update for DEV" + process_file_updates "$INPUT_GITOPS_DEV" "true" + +elif [[ $GITHUB_REF == refs/tags/* && -n "${INPUT_GITOPS_PROD:-}" ]]; then + log_info "Run update for PROD" + process_file_updates "$INPUT_GITOPS_PROD" "true" + +elif [[ -n "${INPUT_GITOPS_DEV:-}" ]]; then + log_info "Simulate update for DEV" + process_file_updates "$INPUT_GITOPS_DEV" "false" +fi diff --git a/scripts/verify-architecture.sh b/scripts/verify-architecture.sh new file mode 100755 index 0000000..009d87b --- /dev/null +++ b/scripts/verify-architecture.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Verifies that the runner CPU architecture matches the requested build platforms. +# Prevents emulation-based builds which are slow and unreliable. +# +# Required env vars: RUNNER_ARCH, INPUT_DOCKER_BUILD_PLATFORMS + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" + +require_env RUNNER_ARCH +require_env INPUT_DOCKER_BUILD_PLATFORMS + +echo "Runner CPU Architecture: $RUNNER_ARCH" +echo "Requested Build Platforms: $INPUT_DOCKER_BUILD_PLATFORMS" + +if [[ "$RUNNER_ARCH" == "X64" ]]; then + if [[ "$INPUT_DOCKER_BUILD_PLATFORMS" == *"linux/arm64"* ]]; then + log_error "Runner is X64 (Intel/AMD) but build includes 'linux/arm64'. This requires emulation. Aborting strictly." + exit 1 + fi +fi + +if [[ "$RUNNER_ARCH" == "ARM64" ]]; then + if [[ "$INPUT_DOCKER_BUILD_PLATFORMS" == *"linux/amd64"* ]]; then + log_error "Runner is ARM64 (Apple Silicon/Graviton) but build includes 'linux/amd64'. This requires emulation. Aborting strictly." + exit 1 + fi +fi + +echo "Architecture match verified for native build" diff --git a/tests/fixtures/sample-app.yaml b/tests/fixtures/sample-app.yaml new file mode 100644 index 0000000..b8c5036 --- /dev/null +++ b/tests/fixtures/sample-app.yaml @@ -0,0 +1,12 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-service + namespace: my-service + annotations: {} +spec: + template: + spec: + containers: + - name: app + image: registry.staffbase.com/my-service:old-tag diff --git a/tests/generate-tags.bats b/tests/generate-tags.bats new file mode 100644 index 0000000..27323c0 --- /dev/null +++ b/tests/generate-tags.bats @@ -0,0 +1,171 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +SCRIPT="${BATS_TEST_DIRNAME}/../scripts/generate-tags.sh" + +setup() { + setup_common + export GITHUB_SHA="abcdef1234567890" + export INPUT_DOCKER_REGISTRY="registry.staffbase.com" + export INPUT_DOCKER_IMAGE="my-service" + export INPUT_DOCKER_CUSTOM_TAG="" + export INPUT_DOCKER_DISABLE_RETAGGING="false" +} + +teardown() { + teardown_common +} + +# --- main branch --- + +@test "main branch generates correct tag" { + export GITHUB_REF="refs/heads/main" + run "$SCRIPT" + assert_success + assert_output_value "tag" "main-abcdef12" + assert_output_value "latest" "main" + assert_output_value "push" "true" + assert_output_value "build" "true" +} + +# --- master branch --- + +@test "master branch generates correct tag" { + export GITHUB_REF="refs/heads/master" + run "$SCRIPT" + assert_success + assert_output_value "tag" "master-abcdef12" + assert_output_value "latest" "master" + assert_output_value "push" "true" + assert_output_value "build" "true" +} + +# --- dev branch --- + +@test "dev branch generates correct tag" { + export GITHUB_REF="refs/heads/dev" + run "$SCRIPT" + assert_success + assert_output_value "tag" "dev-abcdef12" + assert_output_value "latest" "dev" + assert_output_value "push" "true" + assert_output_value "build" "true" +} + +# --- version tag --- + +@test "version tag v1.2.3 generates correct tag" { + export GITHUB_REF="refs/tags/v1.2.3" + run "$SCRIPT" + assert_success + assert_output_value "tag" "1.2.3" + assert_output_value "latest" "latest" + assert_output_value "push" "true" +} + +@test "version tag uses docker-disable-retagging for build flag" { + export GITHUB_REF="refs/tags/v1.2.3" + export INPUT_DOCKER_DISABLE_RETAGGING="true" + run "$SCRIPT" + assert_success + assert_output_value "build" "true" +} + +@test "version tag defaults build to false when retagging enabled" { + export GITHUB_REF="refs/tags/v1.2.3" + export INPUT_DOCKER_DISABLE_RETAGGING="false" + run "$SCRIPT" + assert_success + assert_output_value "build" "false" +} + +# --- non-version tag --- + +@test "non-version tag generates correct tag" { + export GITHUB_REF="refs/tags/release-1" + run "$SCRIPT" + assert_success + assert_output_value "tag" "release-1" + assert_output_value "latest" "latest" + assert_output_value "push" "true" +} + +# --- feature branch --- + +@test "feature branch generates SHA tag with no push" { + export GITHUB_REF="refs/heads/feature/my-feature" + run "$SCRIPT" + assert_success + assert_output_value "tag" "abcdef12" + assert_output_value "push" "false" + assert_output_value "build" "true" + assert_output_value "latest" "" +} + +# --- custom tag --- + +@test "custom tag overrides branch logic" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_CUSTOM_TAG="my-custom-tag" + run "$SCRIPT" + assert_success + assert_output_value "tag" "my-custom-tag" + assert_output_value "latest" "latest" + assert_output_value "push" "true" +} + +@test "custom tag with retagging disabled sets build to true" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_CUSTOM_TAG="my-custom-tag" + export INPUT_DOCKER_DISABLE_RETAGGING="true" + run "$SCRIPT" + assert_success + assert_output_value "build" "true" +} + +@test "custom tag with retagging enabled sets build to false" { + export GITHUB_REF="refs/heads/main" + export INPUT_DOCKER_CUSTOM_TAG="my-custom-tag" + export INPUT_DOCKER_DISABLE_RETAGGING="false" + run "$SCRIPT" + assert_success + assert_output_value "build" "false" +} + +# --- tag_list format --- + +@test "tag_list includes registry and image" { + export GITHUB_REF="refs/heads/main" + run "$SCRIPT" + assert_success + local tag_list + tag_list=$(get_output_value "tag_list") + [[ "$tag_list" == "registry.staffbase.com/my-service:main-abcdef12,registry.staffbase.com/my-service:main" ]] +} + +@test "tag_list has no latest suffix for feature branches" { + export GITHUB_REF="refs/heads/feature/test" + run "$SCRIPT" + assert_success + local tag_list + tag_list=$(get_output_value "tag_list") + [[ "$tag_list" == "registry.staffbase.com/my-service:abcdef12" ]] +} + +# --- validation --- + +@test "fails when GITHUB_REF is missing" { + unset GITHUB_REF + run "$SCRIPT" + assert_failure + assert_output --partial "GITHUB_REF" +} + +@test "fails when INPUT_DOCKER_IMAGE is missing" { + export GITHUB_REF="refs/heads/main" + unset INPUT_DOCKER_IMAGE + run "$SCRIPT" + assert_failure + assert_output --partial "INPUT_DOCKER_IMAGE" +} diff --git a/tests/lib-common.bats b/tests/lib-common.bats new file mode 100644 index 0000000..3ec1d0a --- /dev/null +++ b/tests/lib-common.bats @@ -0,0 +1,126 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +setup() { + setup_common + source "${BATS_TEST_DIRNAME}/../scripts/lib/common.sh" +} + +teardown() { + teardown_common +} + +# --- log_info --- + +@test "log_info outputs notice format" { + run log_info "test message" + assert_success + assert_output "::notice::test message" +} + +# --- log_warn --- + +@test "log_warn outputs warning format" { + run log_warn "warning message" + assert_success + assert_output "::warning::warning message" +} + +# --- log_error --- + +@test "log_error outputs error format to stderr" { + run log_error "error message" + assert_success + assert_output "::error::error message" +} + +# --- set_output --- + +@test "set_output writes to GITHUB_OUTPUT" { + set_output "my_key" "my_value" + assert_output_value "my_key" "my_value" +} + +@test "set_output handles empty value" { + set_output "empty_key" "" + assert_output_value "empty_key" "" +} + +@test "set_output handles value with special characters" { + set_output "special" "hello=world,foo:bar" + assert_output_value "special" "hello=world,foo:bar" +} + +@test "set_output falls back to stdout when GITHUB_OUTPUT is unset" { + unset GITHUB_OUTPUT + run set_output "key" "value" + assert_success + assert_output "OUTPUT key=value" +} + +# --- require_env --- + +@test "require_env succeeds when variable is set" { + export MY_VAR="hello" + run require_env "MY_VAR" + assert_success +} + +@test "require_env fails when variable is empty" { + export MY_VAR="" + run require_env "MY_VAR" + assert_failure + assert_output --partial "Required environment variable 'MY_VAR'" +} + +@test "require_env fails when variable is unset" { + unset MY_VAR + run require_env "MY_VAR" + assert_failure + assert_output --partial "Required environment variable 'MY_VAR'" +} + +# --- require_tool --- + +@test "require_tool succeeds for existing tool" { + run require_tool "bash" + assert_success +} + +@test "require_tool fails for non-existing tool" { + run require_tool "nonexistent_tool_xyz" + assert_failure + assert_output --partial "Required tool 'nonexistent_tool_xyz'" +} + +# --- retry_with_backoff --- + +@test "retry_with_backoff succeeds on first try" { + run retry_with_backoff 3 1 true + assert_success +} + +@test "retry_with_backoff fails after exhausting attempts" { + run retry_with_backoff 2 0 false + assert_failure + assert_output --partial "failed after 2 attempts" +} + +@test "retry_with_backoff succeeds on later attempt" { + COUNTER_FILE="${TEST_TEMP_DIR}/counter" + echo "0" > "$COUNTER_FILE" + + flaky_command() { + local count + count=$(cat "$COUNTER_FILE") + count=$((count + 1)) + echo "$count" > "$COUNTER_FILE" + [[ $count -ge 3 ]] + } + export -f flaky_command + export COUNTER_FILE + + run retry_with_backoff 5 0 flaky_command + assert_success +} diff --git a/tests/lib-gitops-functions.bats b/tests/lib-gitops-functions.bats new file mode 100644 index 0000000..6e47903 --- /dev/null +++ b/tests/lib-gitops-functions.bats @@ -0,0 +1,181 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +setup() { + setup_common + source "${BATS_TEST_DIRNAME}/../scripts/lib/common.sh" + source "${BATS_TEST_DIRNAME}/../scripts/lib/gitops-functions.sh" + + export GITHUB_REPOSITORY="Staffbase/my-service" + export GITHUB_SHA="abcdef1234567890" + export INPUT_DOCKER_REGISTRY="registry.staffbase.com" + export INPUT_DOCKER_IMAGE="my-service" + export INPUT_TAG="main-abcdef12" + export INPUT_PUSH="true" + export INPUT_CREATE_DEPLOYMENT="false" + export INPUT_DEPLOYMENT_IDS="{}" + export INPUT_GITOPS_USER="Staffbot" + export INPUT_GITOPS_TOKEN="fake-token" + export INPUT_GITOPS_ORGANIZATION="Staffbase" + export INPUT_GITOPS_REPOSITORY="mops" + export IMAGE="registry.staffbase.com/my-service:main-abcdef12" + + # Create mock yq + mkdir -p "${TEST_TEMP_DIR}/mocks" + cat > "${TEST_TEMP_DIR}/mocks/yq" << 'YQ_MOCK' +#!/usr/bin/env bash +echo "yq $*" >> "${MOCK_CALLS_DIR}/yq_calls.log" +# For -e (evaluate/check), just succeed +# For -i (in-place edit), do nothing +exit 0 +YQ_MOCK + chmod +x "${TEST_TEMP_DIR}/mocks/yq" + export MOCK_CALLS_DIR="$TEST_TEMP_DIR" + + # Create mock git + cat > "${TEST_TEMP_DIR}/mocks/git" << 'GIT_MOCK' +#!/usr/bin/env bash +echo "git $*" >> "${MOCK_CALLS_DIR}/git_calls.log" +case "$1" in + diff-index) exit 1 ;; # simulate changes exist + *) exit 0 ;; +esac +GIT_MOCK + chmod +x "${TEST_TEMP_DIR}/mocks/git" + + # Create mock jq that passes through + cat > "${TEST_TEMP_DIR}/mocks/jq" << 'JQ_MOCK' +#!/usr/bin/env bash +# Use real jq if available, otherwise simple passthrough +if command -v /usr/bin/jq &>/dev/null; then + /usr/bin/jq "$@" +elif command -v /opt/homebrew/bin/jq &>/dev/null; then + /opt/homebrew/bin/jq "$@" +else + cat +fi +JQ_MOCK + chmod +x "${TEST_TEMP_DIR}/mocks/jq" + + export PATH="${TEST_TEMP_DIR}/mocks:$PATH" +} + +teardown() { + teardown_common +} + +# --- derive_environment --- + +@test "derive_environment extracts env and cluster from standard mops path" { + run derive_environment "kubernetes/namespaces/my-service/prod/de1/deployment.yaml" + assert_success + assert_output "prod-de1" +} + +@test "derive_environment handles stage environment" { + run derive_environment "kubernetes/namespaces/my-service/stage/us1/deployment.yaml" + assert_success + assert_output "stage-us1" +} + +@test "derive_environment handles dev environment" { + run derive_environment "kubernetes/namespaces/my-service/dev/de1/deployment.yaml" + assert_success + assert_output "dev-de1" +} + +# --- update_file --- + +@test "update_file calls yq to check and update field" { + update_file "deployment.yaml" "spec.image" "$IMAGE" + assert [ -f "${TEST_TEMP_DIR}/yq_calls.log" ] + grep -q 'yq -e .spec.image deployment.yaml' "${TEST_TEMP_DIR}/yq_calls.log" + grep -q 'yq -i' "${TEST_TEMP_DIR}/yq_calls.log" +} + +@test "update_file writes deployment annotations when create-deployment is true" { + export INPUT_CREATE_DEPLOYMENT="true" + update_file "kubernetes/namespaces/svc/prod/de1/deployment.yaml" "spec.image" "$IMAGE" + grep -q 'deploy.staffbase.com/repo' "${TEST_TEMP_DIR}/yq_calls.log" + grep -q 'deploy.staffbase.com/sha' "${TEST_TEMP_DIR}/yq_calls.log" +} + +@test "update_file skips annotations when create-deployment is false" { + export INPUT_CREATE_DEPLOYMENT="false" + update_file "deployment.yaml" "spec.image" "$IMAGE" + ! grep -q 'deploy.staffbase.com' "${TEST_TEMP_DIR}/yq_calls.log" 2>/dev/null || true +} + +@test "update_file writes deployment-id annotation when deployment ID is available" { + export INPUT_CREATE_DEPLOYMENT="true" + export INPUT_DEPLOYMENT_IDS='{"prod-de1":"12345"}' + update_file "kubernetes/namespaces/svc/prod/de1/deployment.yaml" "spec.image" "$IMAGE" + grep -q 'deploy.staffbase.com/deployment-id' "${TEST_TEMP_DIR}/yq_calls.log" +} + +@test "update_file skips deployment-id annotation when no matching deployment ID" { + export INPUT_CREATE_DEPLOYMENT="true" + export INPUT_DEPLOYMENT_IDS='{"stage-us1":"99999"}' + update_file "kubernetes/namespaces/svc/prod/de1/deployment.yaml" "spec.image" "$IMAGE" + ! grep -q 'deployment-id' "${TEST_TEMP_DIR}/yq_calls.log" +} + +# --- commit_changes --- + +@test "commit_changes commits and pushes when push is true" { + commit_changes + grep -q 'git add' "${TEST_TEMP_DIR}/git_calls.log" + grep -q 'git commit' "${TEST_TEMP_DIR}/git_calls.log" + grep -q 'git push' "${TEST_TEMP_DIR}/git_calls.log" +} + +@test "commit_changes skips when push is false" { + export INPUT_PUSH="false" + commit_changes + [[ ! -f "${TEST_TEMP_DIR}/git_calls.log" ]] +} + +@test "commit_changes skips commit when no changes" { + # Override git mock: diff-index returns 0 (no changes) + cat > "${TEST_TEMP_DIR}/mocks/git" << 'GIT_MOCK' +#!/usr/bin/env bash +echo "git $*" >> "${MOCK_CALLS_DIR}/git_calls.log" +exit 0 +GIT_MOCK + chmod +x "${TEST_TEMP_DIR}/mocks/git" + + commit_changes + ! grep -q 'git commit' "${TEST_TEMP_DIR}/git_calls.log" +} + +# --- process_file_updates --- + +@test "process_file_updates processes multi-line input" { + local file_list="file1.yaml spec.image +file2.yaml spec.container.image" + process_file_updates "$file_list" "false" + local yq_count + yq_count=$(grep -c 'yq -e' "${TEST_TEMP_DIR}/yq_calls.log") + [[ "$yq_count" -eq 2 ]] +} + +@test "process_file_updates skips empty lines" { + local file_list="file1.yaml spec.image + +file2.yaml spec.image" + process_file_updates "$file_list" "false" + local yq_count + yq_count=$(grep -c 'yq -e' "${TEST_TEMP_DIR}/yq_calls.log") + [[ "$yq_count" -eq 2 ]] +} + +@test "process_file_updates commits when should_commit is true" { + process_file_updates "file1.yaml spec.image" "true" + grep -q 'git commit' "${TEST_TEMP_DIR}/git_calls.log" +} + +@test "process_file_updates skips commit when should_commit is false" { + process_file_updates "file1.yaml spec.image" "false" + ! grep -q 'git commit' "${TEST_TEMP_DIR}/git_calls.log" 2>/dev/null || true +} diff --git a/tests/retag-image.bats b/tests/retag-image.bats new file mode 100644 index 0000000..d1d90d7 --- /dev/null +++ b/tests/retag-image.bats @@ -0,0 +1,91 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +SCRIPT="${BATS_TEST_DIRNAME}/../scripts/retag-image.sh" + +setup() { + setup_common + export GITHUB_SHA="abcdef1234567890" + export INPUT_DOCKER_USERNAME="user" + export INPUT_DOCKER_PASSWORD="pass" + export INPUT_DOCKER_REGISTRY_API="https://registry.example.com/v2/" + export INPUT_DOCKER_IMAGE="my-service" + export INPUT_TAG="1.0.0" + export INPUT_LATEST="latest" + export RETAG_TIMEOUT_SECONDS="2" + export RETAG_POLL_INTERVAL="0" + + # Create mock curl + mkdir -p "${TEST_TEMP_DIR}/mocks" + export PATH="${TEST_TEMP_DIR}/mocks:$PATH" +} + +teardown() { + teardown_common +} + +create_curl_mock() { + local behavior="$1" + cat > "${TEST_TEMP_DIR}/mocks/curl" << MOCK_EOF +#!/usr/bin/env bash +# Record call +echo "curl \$*" >> "${TEST_TEMP_DIR}/curl_calls.log" + +# Handle different call patterns +case "\$*" in + *manifests/master-*|*manifests/main-*) + if [[ "$behavior" == "found" ]]; then + # Write mock headers + if [[ "\$*" == *"-D "* ]]; then + headers_file=\$(echo "\$*" | sed 's/.*-D \([^ ]*\).*/\1/') + cat > "\$headers_file" << 'HEADERS' +Content-Type: application/vnd.docker.distribution.manifest.v2+json +Docker-Content-Digest: sha256:abc123def456 +HEADERS + fi + echo '{"schemaVersion": 2}' + else + echo '{"errors": [{"code": "MANIFEST_UNKNOWN"}]}' + fi + ;; + *"--fail-with-body"*"-X PUT"*) + echo "PUT OK" + ;; +esac +MOCK_EOF + chmod +x "${TEST_TEMP_DIR}/mocks/curl" +} + +@test "retag succeeds when image is found immediately" { + create_curl_mock "found" + run "$SCRIPT" + assert_success + assert_output --partial "Image found for" + assert_output --partial "Retagging image" + assert_output_value "digest" "sha256:abc123def456" +} + +@test "retag fails when image is never found within timeout" { + create_curl_mock "not_found" + run "$SCRIPT" + assert_failure + assert_output --partial "No image found" + assert_output --partial "within 2 seconds" +} + +# --- validation --- + +@test "fails when INPUT_DOCKER_USERNAME is missing" { + unset INPUT_DOCKER_USERNAME + run "$SCRIPT" + assert_failure + assert_output --partial "INPUT_DOCKER_USERNAME" +} + +@test "fails when INPUT_DOCKER_IMAGE is missing" { + unset INPUT_DOCKER_IMAGE + run "$SCRIPT" + assert_failure + assert_output --partial "INPUT_DOCKER_IMAGE" +} diff --git a/tests/test_helper/setup.bash b/tests/test_helper/setup.bash new file mode 100644 index 0000000..bc5d60c --- /dev/null +++ b/tests/test_helper/setup.bash @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Common test helper for bats tests. +# Provides mock setup, temporary directories, and assertion helpers. + +load "${BATS_TEST_DIRNAME}/test_helper/bats-support/load" +load "${BATS_TEST_DIRNAME}/test_helper/bats-assert/load" + +setup_common() { + TEST_TEMP_DIR="$(mktemp -d)" + export GITHUB_OUTPUT="${TEST_TEMP_DIR}/github_output" + touch "$GITHUB_OUTPUT" +} + +teardown_common() { + rm -rf "$TEST_TEMP_DIR" +} + +# Assert that a specific output was written to GITHUB_OUTPUT +assert_output_value() { + local name="$1" + local expected="$2" + local actual + actual=$(grep "^${name}=" "$GITHUB_OUTPUT" | head -1 | cut -d'=' -f2-) + if [[ "$actual" != "$expected" ]]; then + echo "Expected output ${name}='${expected}', got '${actual}'" >&2 + echo "Full GITHUB_OUTPUT contents:" >&2 + cat "$GITHUB_OUTPUT" >&2 + return 1 + fi +} + +# Get value of a specific output from GITHUB_OUTPUT +get_output_value() { + local name="$1" + grep "^${name}=" "$GITHUB_OUTPUT" | head -1 | cut -d'=' -f2- +} + +# Create a mock command that records calls and returns configured output +create_mock() { + local cmd_name="$1" + local mock_script="${TEST_TEMP_DIR}/mocks/${cmd_name}" + mkdir -p "${TEST_TEMP_DIR}/mocks" + cat > "$mock_script" << 'MOCK_EOF' +#!/usr/bin/env bash +echo "$0 $*" >> "${MOCK_CALLS_DIR:-/tmp}/mock_calls.log" +MOCK_EOF + chmod +x "$mock_script" + export PATH="${TEST_TEMP_DIR}/mocks:$PATH" + export MOCK_CALLS_DIR="$TEST_TEMP_DIR" +} diff --git a/tests/update-gitops.bats b/tests/update-gitops.bats new file mode 100644 index 0000000..6cafd96 --- /dev/null +++ b/tests/update-gitops.bats @@ -0,0 +1,142 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +SCRIPT="${BATS_TEST_DIRNAME}/../scripts/update-gitops.sh" + +setup() { + setup_common + export GITHUB_SHA="abcdef1234567890" + export GITHUB_REPOSITORY="Staffbase/my-service" + export INPUT_DOCKER_REGISTRY="registry.staffbase.com" + export INPUT_DOCKER_IMAGE="my-service" + export INPUT_TAG="main-abcdef12" + export INPUT_PUSH="true" + export INPUT_CREATE_DEPLOYMENT="false" + export INPUT_DEPLOYMENT_IDS="{}" + export INPUT_GITOPS_USER="Staffbot" + export INPUT_GITOPS_EMAIL="staffbot@staffbase.com" + export INPUT_GITOPS_TOKEN="fake-token" + export INPUT_GITOPS_ORGANIZATION="Staffbase" + export INPUT_GITOPS_REPOSITORY="mops" + export INPUT_GITOPS_DEV="" + export INPUT_GITOPS_STAGE="" + export INPUT_GITOPS_PROD="" + + # Create mocks + mkdir -p "${TEST_TEMP_DIR}/mocks" + export MOCK_CALLS_DIR="$TEST_TEMP_DIR" + + cat > "${TEST_TEMP_DIR}/mocks/yq" << 'MOCK' +#!/usr/bin/env bash +echo "yq $*" >> "${MOCK_CALLS_DIR}/yq_calls.log" +exit 0 +MOCK + chmod +x "${TEST_TEMP_DIR}/mocks/yq" + + cat > "${TEST_TEMP_DIR}/mocks/git" << 'MOCK' +#!/usr/bin/env bash +echo "git $*" >> "${MOCK_CALLS_DIR}/git_calls.log" +case "$1" in + diff-index) exit 1 ;; # changes exist + *) exit 0 ;; +esac +MOCK + chmod +x "${TEST_TEMP_DIR}/mocks/git" + + cat > "${TEST_TEMP_DIR}/mocks/jq" << 'MOCK' +#!/usr/bin/env bash +if command -v /usr/bin/jq &>/dev/null; then + /usr/bin/jq "$@" +elif command -v /opt/homebrew/bin/jq &>/dev/null; then + /opt/homebrew/bin/jq "$@" +else + cat +fi +MOCK + chmod +x "${TEST_TEMP_DIR}/mocks/jq" + + export PATH="${TEST_TEMP_DIR}/mocks:$PATH" +} + +teardown() { + teardown_common +} + +# --- STAGE updates on main --- + +@test "updates STAGE files on main branch" { + export GITHUB_REF="refs/heads/main" + export INPUT_GITOPS_STAGE="kubernetes/namespaces/svc/stage/de1/deploy.yaml spec.image" + run "$SCRIPT" + assert_success + assert_output --partial "Run update for STAGE" + grep -q 'yq -e' "${TEST_TEMP_DIR}/yq_calls.log" + grep -q 'git commit' "${TEST_TEMP_DIR}/git_calls.log" +} + +@test "updates STAGE files on master branch" { + export GITHUB_REF="refs/heads/master" + export INPUT_GITOPS_STAGE="kubernetes/namespaces/svc/stage/de1/deploy.yaml spec.image" + run "$SCRIPT" + assert_success + assert_output --partial "Run update for STAGE" +} + +# --- DEV updates on dev --- + +@test "updates DEV files on dev branch" { + export GITHUB_REF="refs/heads/dev" + export INPUT_GITOPS_DEV="kubernetes/namespaces/svc/dev/de1/deploy.yaml spec.image" + run "$SCRIPT" + assert_success + assert_output --partial "Run update for DEV" +} + +# --- PROD updates on tags --- + +@test "updates PROD files on tag" { + export GITHUB_REF="refs/tags/v1.0.0" + export INPUT_GITOPS_PROD="kubernetes/namespaces/svc/prod/de1/deploy.yaml spec.image" + run "$SCRIPT" + assert_success + assert_output --partial "Run update for PROD" +} + +# --- Simulate on feature branch --- + +@test "simulates DEV update on feature branch" { + export GITHUB_REF="refs/heads/feature/test" + export INPUT_GITOPS_DEV="kubernetes/namespaces/svc/dev/de1/deploy.yaml spec.image" + run "$SCRIPT" + assert_success + assert_output --partial "Simulate update for DEV" + # Should NOT commit + ! grep -q 'git commit' "${TEST_TEMP_DIR}/git_calls.log" 2>/dev/null || true +} + +# --- No files configured --- + +@test "does nothing when no gitops files are configured" { + export GITHUB_REF="refs/heads/main" + run "$SCRIPT" + assert_success + [[ ! -f "${TEST_TEMP_DIR}/yq_calls.log" ]] +} + +# --- validation --- + +@test "fails when GITHUB_REF is missing" { + unset GITHUB_REF + run "$SCRIPT" + assert_failure + assert_output --partial "GITHUB_REF" +} + +@test "fails when INPUT_GITOPS_TOKEN is missing" { + export GITHUB_REF="refs/heads/main" + unset INPUT_GITOPS_TOKEN + run "$SCRIPT" + assert_failure + assert_output --partial "INPUT_GITOPS_TOKEN" +} diff --git a/tests/verify-architecture.bats b/tests/verify-architecture.bats new file mode 100644 index 0000000..d0678bc --- /dev/null +++ b/tests/verify-architecture.bats @@ -0,0 +1,75 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +SCRIPT="${BATS_TEST_DIRNAME}/../scripts/verify-architecture.sh" + +setup() { + setup_common +} + +teardown() { + teardown_common +} + +# --- X64 runner --- + +@test "X64 runner with amd64 target passes" { + export RUNNER_ARCH="X64" + export INPUT_DOCKER_BUILD_PLATFORMS="linux/amd64" + run "$SCRIPT" + assert_success + assert_output --partial "Architecture match verified" +} + +@test "X64 runner with arm64 target fails" { + export RUNNER_ARCH="X64" + export INPUT_DOCKER_BUILD_PLATFORMS="linux/arm64" + run "$SCRIPT" + assert_failure + assert_output --partial "requires emulation" +} + +@test "X64 runner with multi-arch target fails" { + export RUNNER_ARCH="X64" + export INPUT_DOCKER_BUILD_PLATFORMS="linux/amd64,linux/arm64" + run "$SCRIPT" + assert_failure + assert_output --partial "requires emulation" +} + +# --- ARM64 runner --- + +@test "ARM64 runner with arm64 target passes" { + export RUNNER_ARCH="ARM64" + export INPUT_DOCKER_BUILD_PLATFORMS="linux/arm64" + run "$SCRIPT" + assert_success + assert_output --partial "Architecture match verified" +} + +@test "ARM64 runner with amd64 target fails" { + export RUNNER_ARCH="ARM64" + export INPUT_DOCKER_BUILD_PLATFORMS="linux/amd64" + run "$SCRIPT" + assert_failure + assert_output --partial "requires emulation" +} + +# --- validation --- + +@test "fails when RUNNER_ARCH is missing" { + unset RUNNER_ARCH + export INPUT_DOCKER_BUILD_PLATFORMS="linux/amd64" + run "$SCRIPT" + assert_failure + assert_output --partial "RUNNER_ARCH" +} + +@test "fails when INPUT_DOCKER_BUILD_PLATFORMS is missing" { + export RUNNER_ARCH="X64" + unset INPUT_DOCKER_BUILD_PLATFORMS + run "$SCRIPT" + assert_failure + assert_output --partial "INPUT_DOCKER_BUILD_PLATFORMS" +} From d46d282aabbcc93f68495184ed1afd37e4a274cf Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Thu, 23 Apr 2026 09:14:13 +0200 Subject: [PATCH 2/5] fix: use mise in CI workflow for tool installation Replace manual npm/git-clone installation of bats and shellcheck with jdx/mise-action, consistent with the local mise.toml setup. Co-Authored-By: Claude --- .github/workflows/ci.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e783a0b..a8e5167 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,15 +10,18 @@ permissions: contents: read jobs: - shellcheck: + lint: name: Shellcheck runs-on: ubuntu-24.04 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Run shellcheck on all scripts - run: find scripts/ -name '*.sh' -exec shellcheck --severity=warning {} + + - name: Install mise + uses: jdx/mise-action@v2 + + - name: Run shellcheck + run: mise run lint test: name: Bash Tests @@ -27,14 +30,11 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Install bats and helpers - run: | - sudo npm install -g bats - git clone --depth 1 https://github.com/bats-core/bats-support.git tests/test_helper/bats-support - git clone --depth 1 https://github.com/bats-core/bats-assert.git tests/test_helper/bats-assert + - name: Install mise + uses: jdx/mise-action@v2 - name: Run tests - run: bats tests/*.bats + run: mise run test validate-action: name: Validate Action Structure From 1dde6743bf20d2d42a9bc77487b82791a8697980 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Thu, 23 Apr 2026 09:14:59 +0200 Subject: [PATCH 3/5] chore: pin jdx/mise-action to full commit SHA Co-Authored-By: Claude --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e5167..0f4fe84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install mise - uses: jdx/mise-action@v2 + uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2 - name: Run shellcheck run: mise run lint @@ -31,7 +31,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install mise - uses: jdx/mise-action@v2 + uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2 - name: Run tests run: mise run test From 8d7d9dbf52989f74367fd063c39026d8d127daf1 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Thu, 23 Apr 2026 09:15:59 +0200 Subject: [PATCH 4/5] fix: use correct mise registry name for bats Co-Authored-By: Claude --- mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index ab0bd29..55ffd61 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -bats-core = "1.11.0" +bats = "1.11.0" shellcheck = "0.10.0" [tasks.test] From 8e6fd8d77bca482efcacc0e497e89cb1f0f2b120 Mon Sep 17 00:00:00 2001 From: Falk Scheerschmidt Date: Thu, 23 Apr 2026 09:59:23 +0200 Subject: [PATCH 5/5] chore: update actions/github-script to v9 (Node.js 24) Fixes Node.js 20 deprecation warning. Node.js 20 actions will be forced to Node.js 24 starting June 2nd, 2026. Co-Authored-By: Claude --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index f95c7ae..6ef9d11 100644 --- a/action.yml +++ b/action.yml @@ -190,7 +190,7 @@ runs: - name: Create GitHub Deployments id: create_deployments if: inputs.create-deployment == 'true' && inputs.github-token != '' && inputs.gitops-token != '' - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: INPUT_GITOPS_DEV: ${{ inputs.gitops-dev }} INPUT_GITOPS_STAGE: ${{ inputs.gitops-stage }}