diff --git a/.github/workflows/internal--image-builder.yaml b/.github/workflows/internal--image-builder.yaml index 4649b6f..f6992c6 100644 --- a/.github/workflows/internal--image-builder.yaml +++ b/.github/workflows/internal--image-builder.yaml @@ -12,6 +12,11 @@ on: required: true type: string description: 'Directory containing the Dockerfile' + ref: + required: false + type: string + default: '' + description: 'Git ref to build (empty uses the default ref of the triggering event)' app-id: required: true type: string @@ -49,6 +54,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ inputs.ref }} - name: Check for Dockerfile id: dockerfile @@ -80,7 +86,7 @@ jobs: tags: | type=sha type=ref,event=pr - type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ inputs.ref == '' && '{{is_default_branch}}' || 'false' }} type=raw,value=${{ github.actor }} type=semver,pattern={{raw}},value=${{ inputs.semver-tag }},enable=${{ inputs.semver-tag != '' }} type=raw,value=${{ inputs.release-tag }},enable=${{ inputs.release-tag != '' }} diff --git a/.github/workflows/reusable--label-base-deploy--resolver.yaml b/.github/workflows/reusable--label-base-deploy--resolver.yaml index abdd66f..f3c82c4 100644 --- a/.github/workflows/reusable--label-base-deploy--resolver.yaml +++ b/.github/workflows/reusable--label-base-deploy--resolver.yaml @@ -122,29 +122,45 @@ jobs: helmfile-targets-group: name: 'Group Helmfile Targets by Environment' needs: resolve - if: | - needs.resolve.outputs.has-targets == 'true' && - contains(needs.resolve.outputs.targets, '"type":"helmfile"') + if: needs.resolve.result == 'success' runs-on: ubuntu-latest outputs: environments: ${{ steps.group.outputs.environments }} steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + - name: Group by environment id: group env: TARGETS: ${{ needs.resolve.outputs.targets }} run: | set -euo pipefail - grouped=$(echo "$TARGETS" | jq -c ' + orphaned=$(bash .shared-workflows/scripts/kubernetes-hydrate/orphan-environments.sh) + # TARGETS is empty when no pull request was found, and jq emits nothing for empty input. + grouped=$(jq -c --argjson orphaned "$orphaned" ' [.[] | select(.type == "helmfile")] | group_by(.environment) | map({environment: .[0].environment, services: [.[].service]}) - ') + | . + (($orphaned - map(.environment)) | map({environment: ., services: []})) + | sort_by(.environment) + ' <<<"${TARGETS:-[]}") echo "environments=$grouped" >> "$GITHUB_OUTPUT" deploy-helmfile-hydrate: name: 'Hydrate Helmfile (${{ matrix.env.environment }})' needs: helmfile-targets-group + if: needs.helmfile-targets-group.outputs.environments != '[]' strategy: matrix: env: ${{ fromJson(needs.helmfile-targets-group.outputs.environments) }} diff --git a/.github/workflows/reusable--release--build.yaml b/.github/workflows/reusable--release--build.yaml index 6bbb814..df10469 100644 --- a/.github/workflows/reusable--release--build.yaml +++ b/.github/workflows/reusable--release--build.yaml @@ -22,6 +22,14 @@ jobs: tag: ${{ steps.parse.outputs.tag }} working-directory: ${{ steps.resolve-dir.outputs.working-directory }} steps: + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + - name: Parse tag id: parse env: @@ -29,15 +37,10 @@ jobs: TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} run: | set -euo pipefail - if [[ ! "$TAG" =~ ^([a-z-]+)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - echo "::error::Tag '$TAG' does not match expected pattern -v" - exit 1 - fi - SERVICE="${BASH_REMATCH[1]}" - VERSION="v${BASH_REMATCH[2]}" + service=$(bash .shared-workflows/scripts/terragrunt-release/release-service.sh --tag "$TAG") { - echo "service=$SERVICE" - echo "version=$VERSION" + echo "service=$service" + echo "version=${TAG#"$service"-}" echo "tag=$TAG" } >> "$GITHUB_OUTPUT" # Container targets carry no environment, so environments is not passed. @@ -71,6 +74,8 @@ jobs: with: image-name: ${{ needs.detect-component.outputs.service }} working-directory: ${{ needs.detect-component.outputs.working-directory }} + # The ref of a dispatched run is not the source of the release. + ref: ${{ needs.detect-component.outputs.tag }} app-id: ${{ inputs.app-id }} semver-tag: ${{ needs.detect-component.outputs.version }} release-tag: ${{ needs.detect-component.outputs.tag }} diff --git a/scripts/kubernetes-hydrate/hydrate-component.sh b/scripts/kubernetes-hydrate/hydrate-component.sh index 1303421..2019cf0 100755 --- a/scripts/kubernetes-hydrate/hydrate-component.sh +++ b/scripts/kubernetes-hydrate/hydrate-component.sh @@ -9,6 +9,12 @@ cd "$(git rev-parse --show-toplevel)" component_dir="kubernetes/components/${component}/${env}" out_dir="kubernetes/manifests/${env}/${component}" +# Flux with prune: true would delete the live resources of an empty manifest. +if [ ! -f "${component_dir}/helmfile.yaml" ] && [ ! -d "${component_dir}/kustomization" ]; then + echo "hydrate-component.sh: ${component_dir} has neither helmfile.yaml nor kustomization/; refusing to write an empty manifest" >&2 + exit 1 +fi + # Explicit --kube-version prevents template divergence caused by varying default helm binary versions. env_hcl="aws/eks/${env}/env.hcl" if [ ! -f "${env_hcl}" ]; then diff --git a/scripts/kubernetes-hydrate/orphan-environments.sh b/scripts/kubernetes-hydrate/orphan-environments.sh new file mode 100755 index 0000000..8bc7eaa --- /dev/null +++ b/scripts/kubernetes-hydrate/orphan-environments.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +# An orphan is what hydrate-index.sh removes, so the two loops must agree. +for env_dir in kubernetes/manifests/*/; do + [ -d "${env_dir}" ] || continue + env=$(basename "${env_dir}") + for dir in "${env_dir}"*/; do + [ -d "${dir}" ] || continue + name=$(basename "${dir}") + [ "${name}" = "00-namespaces" ] && continue + if [ ! -d "kubernetes/components/${name}/${env}" ]; then + printf '%s\n' "${env}" + break + fi + done +done | jq -Rsc 'split("\n") | map(select(length > 0))' diff --git a/scripts/kubernetes-hydrate/render-diff.sh b/scripts/kubernetes-hydrate/render-diff.sh index 9eec558..59a76ea 100755 --- a/scripts/kubernetes-hydrate/render-diff.sh +++ b/scripts/kubernetes-hydrate/render-diff.sh @@ -29,6 +29,11 @@ git worktree add -q --detach "${work}/head" HEAD render() { local side="$1" svc="$2" env="$3" dir="$4" + # hydrate-component.sh refuses a missing component, which here means the side before it was added or after it was deleted. + if [ ! -d "${work}/${side}/kubernetes/components/${svc}/${env}" ]; then + : > "${dir}/${side}.yaml" + return 0 + fi (cd "${work}/${side}" && bash "${script_dir}/hydrate-component.sh" "${svc}" "${env}") > "${dir}/${side}.log" 2>&1 && cp "${work}/${side}/kubernetes/manifests/${env}/${svc}/manifest.yaml" "${dir}/${side}.yaml" } diff --git a/scripts/kubernetes-hydrate/tests/hydrate-component.test.sh b/scripts/kubernetes-hydrate/tests/hydrate-component.test.sh new file mode 100755 index 0000000..4349179 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/hydrate-component.test.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +scripts="${here}/.." +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT +failures=0 + +pass() { echo "ok - $1"; } +fail() { echo "not ok - $1"; failures=$((failures + 1)); } +expect_contains() { if grep -qF -- "$2" "$3"; then pass "$1"; else fail "$1"; sed 's/^/# /' "$3"; fi; } +expect_failure() { if [ "$2" -ne 0 ]; then pass "$1"; else fail "$1"; fi; } +expect_success() { if [ "$2" -eq 0 ]; then pass "$1"; else fail "$1"; sed 's/^/# /' "$3"; fi; } +expect_file() { if [ "$(cat "$2")" = "$3" ]; then pass "$1"; else fail "$1"; sed 's/^/# /' "$2"; fi; } +expect_missing() { if [ ! -e "$2" ]; then pass "$1"; else fail "$1"; fi; } + +repo="${work}/repo" +live="${repo}/kubernetes/manifests/production/namespace-only/manifest.yaml" +mkdir -p "${repo}/aws/eks/production" "$(dirname "${live}")" "${repo}/kubernetes/components/namespace-only/production" +printf 'locals {\n cluster_version = "1.33"\n}\n' > "${repo}/aws/eks/production/env.hcl" +printf 'kind: Namespace\n' > "${repo}/kubernetes/components/namespace-only/production/namespace.yaml" +printf 'kind: Deployment\n' > "${live}" +git -C "${repo}" init -q -b main +git -C "${repo}" -c user.name=test -c user.email=test@example.com add -A +git -C "${repo}" -c user.name=test -c user.email=test@example.com commit -q -m base + +hydrate() { + local status=0 + (cd "${repo}" && bash "${scripts}/hydrate-component.sh" "$@") > "${work}/stdout" 2>&1 || status=$? + echo "${status}" +} + +status="$(hydrate namespace-only production)" +expect_failure "a component with neither input fails" "${status}" +expect_contains "the failure names the directory" "kubernetes/components/namespace-only/production" "${work}/stdout" +expect_file "the failure keeps the existing manifest" "${live}" "kind: Deployment" + +status="$(hydrate missing production)" +expect_failure "a component without a directory fails" "${status}" +expect_contains "the failure names the missing directory" "kubernetes/components/missing/production" "${work}/stdout" +expect_missing "the failure writes no manifest directory" "${repo}/kubernetes/manifests/production/missing" + +bin="${work}/bin" +mkdir -p "${bin}" +printf '#!/usr/bin/env bash\necho "kind: HelmRendered"\n' > "${bin}/helmfile" +chmod +x "${bin}/helmfile" +mkdir -p "${repo}/kubernetes/components/chart/production" +touch "${repo}/kubernetes/components/chart/production/helmfile.yaml" +status="$(PATH="${bin}:${PATH}" hydrate chart production)" +expect_success "a component with helmfile.yaml hydrates" "${status}" "${work}/stdout" +expect_contains "the helmfile output reaches the manifest" "kind: HelmRendered" "${repo}/kubernetes/manifests/production/chart/manifest.yaml" + +mkdir -p "${repo}/kubernetes/components/plain/production/kustomization" +printf 'resources:\n - configmap.yaml\n' > "${repo}/kubernetes/components/plain/production/kustomization/kustomization.yaml" +printf 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: plain\n' > "${repo}/kubernetes/components/plain/production/kustomization/configmap.yaml" +status="$(hydrate plain production)" +expect_success "a component with kustomization/ hydrates" "${status}" "${work}/stdout" +expect_contains "the kustomize output reaches the manifest" "kind: ConfigMap" "${repo}/kubernetes/manifests/production/plain/manifest.yaml" + +exit "${failures}" diff --git a/scripts/kubernetes-hydrate/tests/orphan-environments.test.sh b/scripts/kubernetes-hydrate/tests/orphan-environments.test.sh new file mode 100755 index 0000000..e787f6d --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/orphan-environments.test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +scripts="${here}/.." +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT +failures=0 + +pass() { echo "ok - $1"; } +fail() { echo "not ok - $1"; failures=$((failures + 1)); } +expect_eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1"; echo "# expected: $2"; echo "# actual: $3"; fi; } + +new_repo() { + local repo="${work}/$1" + mkdir -p "${repo}" + git -C "${repo}" init -q -b main + echo "${repo}" +} + +add_dirs() { + local repo="$1" dir + shift + for dir in "$@"; do + mkdir -p "${repo}/kubernetes/${dir}" + touch "${repo}/kubernetes/${dir}/file.yaml" + done +} + +orphans() { + (cd "$1" && bash "${scripts}/orphan-environments.sh") +} + +repo="$(new_repo no-kubernetes)" +expect_eq "a repository without kubernetes/manifests yields an empty list" "[]" "$(orphans "${repo}")" + +repo="$(new_repo components-only)" +add_dirs "${repo}" components/app/production +expect_eq "components without manifests yield an empty list" "[]" "$(orphans "${repo}")" + +repo="$(new_repo clean)" +add_dirs "${repo}" components/app/production manifests/production/app manifests/production/00-namespaces +expect_eq "matching manifests yield an empty list" "[]" "$(orphans "${repo}")" + +repo="$(new_repo namespaces-only)" +add_dirs "${repo}" manifests/production/00-namespaces +expect_eq "00-namespaces is not an orphan" "[]" "$(orphans "${repo}")" + +repo="$(new_repo file-in-environment)" +add_dirs "${repo}" components/app/production manifests/production/app +touch "${repo}/kubernetes/manifests/production/kustomization.yaml" +expect_eq "a file in the environment directory is not an orphan" "[]" "$(orphans "${repo}")" + +repo="$(new_repo one-orphan)" +add_dirs "${repo}" components/app/production manifests/production/app manifests/production/gone +expect_eq "a manifest without a component is an orphan" '["production"]' "$(orphans "${repo}")" + +repo="$(new_repo several-orphans)" +add_dirs "${repo}" manifests/production/a manifests/production/b manifests/develop/a manifests/staging/00-namespaces +expect_eq "each environment with an orphan appears once" '["develop","production"]' "$(orphans "${repo}")" + +repo="$(new_repo other-environment)" +add_dirs "${repo}" components/app/develop manifests/production/app +expect_eq "a component in another environment does not cover the manifest" '["production"]' "$(orphans "${repo}")" + +expect_eq "the result does not depend on the working directory" '["production"]' "$(mkdir -p "${repo}/nested" && cd "${repo}/nested" && bash "${scripts}/orphan-environments.sh")" + +repo="$(new_repo matches-hydrate-index)" +add_dirs "${repo}" components/app/production manifests/production/app manifests/production/gone components/app/develop manifests/develop/app +(cd "${repo}" && bash "${scripts}/hydrate-index.sh" production) +if [ -d "${repo}/kubernetes/manifests/production/gone" ]; then fail "hydrate-index removes the reported orphan"; else pass "hydrate-index removes the reported orphan"; fi +expect_eq "hydrate-index leaves no orphan to report" "[]" "$(orphans "${repo}")" + +exit "${failures}" diff --git a/scripts/kubernetes-hydrate/tests/render-diff.test.sh b/scripts/kubernetes-hydrate/tests/render-diff.test.sh index cc897ad..051a8bc 100755 --- a/scripts/kubernetes-hydrate/tests/render-diff.test.sh +++ b/scripts/kubernetes-hydrate/tests/render-diff.test.sh @@ -44,6 +44,7 @@ git -C "${repo}" -c user.name=test -c user.email=test@example.com commit -q -m b write_deployment "${repo}/kubernetes/components/demo/production" demo nginx:1.1 echo "# changed" >> "${repo}/kubernetes/components/demo/namespace.yaml" git -C "${repo}" rm -q -r kubernetes/components/gone +write_deployment "${repo}/kubernetes/components/added/production" added alpine:3 write_deployment "${repo}/kubernetes/components/pending-only/pending" pending-only busybox:1.36 write_deployment "${repo}/kubernetes/components/broken/production" broken busybox:1.36 printf 'resources:\n - missing.yaml\n' > "${repo}/kubernetes/components/broken/production/kustomization/kustomization.yaml" @@ -60,6 +61,7 @@ expect_contains "links the full diff" "production/demo/manifest.diff" "${summary expect_contains "writes the full diff" "+ - image: nginx:1.1" "${out}/production/demo/manifest.diff" expect_contains "lists a deleted component" "## gone (production)" "${summary}" expect_contains "reports resources of a deleted component as removed" "- Deployment gone" "${summary}" +expect_contains "reports resources of an added component as added" "- Deployment added" "${summary}" expect_contains "reports a render failure instead of aborting" "Rendering failed (head)" "${summary}" expect_contains "skips directories that are not hydrated environments" "Not rendered:" "${summary}" if [ -d "${out}/pending" ]; then fail "does not render directories that are not hydrated environments"; else pass "does not render directories that are not hydrated environments"; fi diff --git a/scripts/terragrunt-release/release-service.sh b/scripts/terragrunt-release/release-service.sh index 27ebbb9..242afa2 100755 --- a/scripts/terragrunt-release/release-service.sh +++ b/scripts/terragrunt-release/release-service.sh @@ -4,13 +4,16 @@ set -euo pipefail mode="${1:-}" value="${2:-}" +# The service name rule of deploy-actions' Entities::Service.name_format. +service_rule='[a-zA-Z0-9_-]+' + case "${mode}" in --tag) - pattern='^([a-z0-9-]+)-v[0-9]+\.[0-9]+\.[0-9]+$' + pattern="^(${service_rule})-v[0-9]+\.[0-9]+\.[0-9]+\$" expected='-vX.Y.Z' ;; --branch) - pattern='^release-please--branches--main--components--([a-z0-9-]+)$' + pattern="^release-please--branches--main--components--(${service_rule})\$" expected='release-please--branches--main--components--' ;; *) diff --git a/scripts/terragrunt-release/test.sh b/scripts/terragrunt-release/test.sh index f8ff091..2621490 100755 --- a/scripts/terragrunt-release/test.sh +++ b/scripts/terragrunt-release/test.sh @@ -38,6 +38,26 @@ test_release_service_parses_tag() { assert_eq "monolith" "$(bash "${script_dir}/release-service.sh" --tag monolith-v1.2.3)" } +test_release_service_keeps_the_hyphens_of_a_tag_service() { + assert_eq "github-oidc-auth" "$(bash "${script_dir}/release-service.sh" --tag github-oidc-auth-v1.2.3)" + assert_eq "api-v2" "$(bash "${script_dir}/release-service.sh" --tag api-v2-v1.2.3)" +} + +test_release_service_accepts_every_service_name_character() { + local service + for service in route53 oauth2-proxy my_service MyService 2fa; do + assert_eq "${service}" "$(bash "${script_dir}/release-service.sh" --tag "${service}-v1.2.3")" + assert_eq "${service}" "$(bash "${script_dir}/release-service.sh" --branch "release-please--branches--main--components--${service}")" + done +} + +test_release_service_rejects_other_service_name_characters() { + assert_fails bash "${script_dir}/release-service.sh" --tag a.b-v1.2.3 + assert_stderr_contains "::error::" + assert_fails bash "${script_dir}/release-service.sh" --branch release-please--branches--main--components--a.b + assert_stderr_contains "::error::" +} + test_release_service_rejects_root_tag() { assert_fails bash "${script_dir}/release-service.sh" --tag v0.1.0 assert_stderr_contains "::error::" diff --git a/scripts/workflows/test.sh b/scripts/workflows/test.sh index 20ddf11..24924e7 100755 --- a/scripts/workflows/test.sh +++ b/scripts/workflows/test.sh @@ -32,6 +32,66 @@ test_resolver_routes_targets_by_type() { yq '.jobs["helmfile-targets-group"].steps[] | select(.id == "group") | .run' "${resolver}" | grep -qF 'select(.type == "helmfile")' } +test_resolver_groups_environments_with_orphaned_manifests() { + local resolver="${workflows_dir}/reusable--label-base-deploy--resolver.yaml" + local job='.jobs["helmfile-targets-group"]' + local run work repo targets + assert_eq "needs.resolve.result == 'success'" "$(yq "${job}.if" "${resolver}")" + assert_eq 'actions/checkout@*' "$(yq "${job}.steps[0].uses | sub(\"@.*\", \"@*\")" "${resolver}")" + assert_eq 'null' "$(yq "${job}.steps[0].with.path" "${resolver}")" + assert_eq '.shared-workflows' "$(yq "${job}.steps[1].with.path" "${resolver}")" + assert_eq '${{ job.workflow_repository }}' "$(yq "${job}.steps[1].with.repository" "${resolver}")" + assert_eq '${{ job.workflow_sha }}' "$(yq "${job}.steps[1].with.ref" "${resolver}")" + run="$(yq "${job}.steps[] | select(.id == \"group\") | .run" "${resolver}")" + assert_eq '${{ needs.resolve.outputs.targets }}' "$(yq "${job}.steps[] | select(.id == \"group\") | .env.TARGETS" "${resolver}")" + assert_eq "0" "$(grep -c '\${{' <<<"${run}" || true)" + + work="$(mktemp -d)" + trap "rm -rf '${work}'" EXIT + group() { + rm -f "${work}/output" + (cd "$1" && GITHUB_OUTPUT="${work}/output" TARGETS="$2" bash -c "${run}") + cat "${work}/output" + } + + repo="${work}/no-manifests" + mkdir -p "${repo}" + git -C "${repo}" init -q + ln -s "${repo_root}" "${repo}/.shared-workflows" + assert_eq 'environments=[]' "$(group "${repo}" '')" + assert_eq 'environments=[]' "$(group "${repo}" '[]')" + targets='[{"service":"api","environment":"develop","type":"terragrunt"},{"service":"coredns","environment":"production","type":"helmfile"},{"service":"cilium","environment":"production","type":"helmfile"}]' + assert_eq 'environments=[{"environment":"production","services":["coredns","cilium"]}]' "$(group "${repo}" "${targets}")" + + repo="${work}/orphans" + mkdir -p "${repo}/kubernetes/manifests/production/gone" "${repo}/kubernetes/manifests/develop/gone" + git -C "${repo}" init -q + ln -s "${repo_root}" "${repo}/.shared-workflows" + assert_eq 'environments=[{"environment":"develop","services":[]},{"environment":"production","services":[]}]' "$(group "${repo}" '')" + assert_eq 'environments=[{"environment":"develop","services":[]},{"environment":"production","services":["coredns","cilium"]}]' "$(group "${repo}" "${targets}")" +} + +test_resolver_hydrates_only_listed_environments() { + local resolver="${workflows_dir}/reusable--label-base-deploy--resolver.yaml" + assert_eq "needs.helmfile-targets-group.outputs.environments != '[]'" "$(yq '.jobs["deploy-helmfile-hydrate"].if' "${resolver}")" + assert_eq '${{ fromJson(needs.helmfile-targets-group.outputs.environments) }}' "$(yq '.jobs["deploy-helmfile-hydrate"].strategy.matrix.env' "${resolver}")" + assert_eq '${{ toJson(matrix.env.services) }}' "$(yq '.jobs["deploy-helmfile-hydrate"].with.services' "${resolver}")" +} + +test_resolver_diffs_helmfile_only_for_helmfile_targets() { + local resolver="${workflows_dir}/reusable--label-base-deploy--resolver.yaml" + local expected + expected="$(printf '%s\n' "github.event_name == 'pull_request' &&" "needs.resolve.outputs.has-targets == 'true' &&" "contains(needs.resolve.outputs.targets, '\"type\":\"helmfile\"')")" + assert_eq "${expected}" "$(yq '.jobs["deploy-helmfile"].if' "${resolver}" | sed '/^$/d')" + assert_eq 'resolve deploy-helmfile-hydrate' "$(yq '.jobs["deploy-helmfile"].needs | join(" ")' "${resolver}")" +} + +test_resolver_summarizes_only_runs_with_targets() { + local resolver="${workflows_dir}/reusable--label-base-deploy--resolver.yaml" + assert_eq "always() && needs.resolve.outputs.has-targets == 'true'" "$(yq '.jobs["deployment-summary"].if' "${resolver}")" + assert_eq 'resolve deploy-terragrunt deploy-container deploy-kustomize deploy-helmfile-hydrate deploy-helmfile' "$(yq '.jobs["deployment-summary"].needs | join(" ")' "${resolver}")" +} + test_label_base_deploy_pins_both_actions_to_one_deploy_actions_commit() { local pin_pattern='^panicboat/deploy-actions/label-[a-z]+@([0-9a-f]{40}) # (v[0-9]+\.[0-9]+\.[0-9]+)$' local name line pins="" @@ -133,6 +193,63 @@ test_release_build_tags_the_release_tag() { grep -qF "type=raw,value=\${{ inputs.release-tag }},enable=\${{ inputs.release-tag != '' }}" "${builder}" } +test_release_build_checks_out_the_release_tag() { + local build="${workflows_dir}/reusable--release--build.yaml" + local builder="${workflows_dir}/internal--image-builder.yaml" + local resolver="${workflows_dir}/reusable--label-base-deploy--resolver.yaml" + assert_eq 'false' "$(yq '.on.workflow_call.inputs.ref.required' "${builder}")" + assert_eq 'string' "$(yq '.on.workflow_call.inputs.ref.type' "${builder}")" + assert_eq "" "$(yq '.on.workflow_call.inputs.ref.default' "${builder}")" + assert_eq '${{ inputs.ref }}' "$(yq '.jobs["build-and-push"].steps[] | select(.name == "Checkout repository") | .with.ref' "${builder}")" + assert_eq '${{ needs.detect-component.outputs.tag }}' "$(yq '.jobs["container-build"].with.ref' "${build}")" + assert_eq 'null' "$(yq '.jobs["deploy-container"].with.ref' "${resolver}")" +} + +test_image_builder_moves_latest_only_without_a_ref() { + local builder="${workflows_dir}/internal--image-builder.yaml" + local tags + tags="$(yq '.jobs["build-and-push"].steps[] | select(.id == "meta") | .with.tags' "${builder}")" + grep -qxF "type=raw,value=latest,enable=\${{ inputs.ref == '' && '{{is_default_branch}}' || 'false' }}" <<<"${tags}" + grep -qxF 'type=sha' <<<"${tags}" + grep -qxF 'type=raw,value=${{ github.actor }}' <<<"${tags}" +} + +test_release_build_parses_tags_with_the_release_service_script() { + local build="${workflows_dir}/reusable--release--build.yaml" + local job='.jobs["detect-component"]' + local step run work status out spec tag service version + assert_eq '.shared-workflows' "$(yq "${job}.steps[0].with.path" "${build}")" + assert_eq '${{ job.workflow_repository }}' "$(yq "${job}.steps[0].with.repository" "${build}")" + assert_eq '${{ job.workflow_sha }}' "$(yq "${job}.steps[0].with.ref" "${build}")" + step="$(yq "${job}.steps[] | select(.id == \"parse\")" "${build}")" + run="$(yq '.run' <<<"${step}")" + assert_eq "0" "$(grep -c '\${{' <<<"${run}" || true)" + assert_eq "0" "$(grep -c '=~' <<<"${run}" || true)" + assert_eq '${{ steps.parse.outputs.version }}' "$(yq "${job}.outputs.version" "${build}")" + assert_eq '${{ needs.detect-component.outputs.version }}' "$(yq '.jobs["container-build"].with["semver-tag"]' "${build}")" + + work="$(mktemp -d)" + trap "rm -rf '${work}'" EXIT + ln -s "${repo_root}" "${work}/.shared-workflows" + for spec in monolith:v0.9.0 github-oidc-auth:v1.2.3 route53:v1.0.0 oauth2-proxy:v10.20.30 my_Service2:v1.0.0 api-v2:v3.0.0; do + service="${spec%%:*}" + version="${spec##*:}" + tag="${service}-${version}" + rm -f "${work}/output" + (cd "${work}" && GITHUB_OUTPUT="${work}/output" TAG="${tag}" bash -c "${run}") + assert_eq "$(printf 'service=%s\nversion=%s\ntag=%s' "${service}" "${version}" "${tag}")" "$(cat "${work}/output")" + done + + for tag in v1.0.0 monolith kubernetes-production a.b-v1.0.0 monolith-v1.2 ''; do + rm -f "${work}/output" + status=0 + out="$(cd "${work}" && GITHUB_OUTPUT="${work}/output" TAG="${tag}" bash -c "${run}" 2>&1)" || status=$? + assert_eq "1" "${status}" + [[ "${out}" == *"::error::"* ]] || { echo " no error annotation: ${out}" >&2; exit 1; } + [ ! -e "${work}/output" ] + done +} + test_release_build_resolves_the_container_directory_with_the_label_resolver() { local build="${workflows_dir}/reusable--release--build.yaml" local pin_pattern='^panicboat/deploy-actions/label-resolver@[0-9a-f]{40} # v[0-9]+\.[0-9]+\.[0-9]+$' @@ -146,7 +263,8 @@ test_release_build_resolves_the_container_directory_with_the_label_resolver() { assert_eq '${{ steps.parse.outputs.tag }}' "$(yq '.with.ref' <<<"${step}")" assert_eq '${{ github.repository }}' "$(yq '.with.repository' <<<"${step}")" assert_eq 'null' "$(yq '.with.environments' <<<"${step}")" - assert_eq "0" "$(yq "[${job}.steps[] | select(.uses == \"actions/checkout@*\")] | length" "${build}")" + assert_eq "0" "$(yq "[${job}.steps[] | select(.uses == \"actions/checkout@*\" and .with.path == null)] | length" "${build}")" + assert_eq "1" "$(yq "[${job}.steps[] | select(.uses == \"actions/checkout@*\")] | length" "${build}")" step="$(yq "${job}.steps[] | select(.id == \"resolve-dir\")" "${build}")" run="$(yq '.run' <<<"${step}")"