From fa53e9bf0cf36307915a7f594fc0e8c948ba365d Mon Sep 17 00:00:00 2001 From: panicboat Date: Sat, 31 Oct 2026 09:19:50 +0900 Subject: [PATCH 1/5] fix: fail hydration when a component has no input A component directory with neither helmfile.yaml nor kustomization/ wrote a 0-byte manifest and exited 0. Flux with prune: true could then delete the live resources. The check runs before the manifest is truncated, so the committed manifest stays. render-diff.sh relied on that empty output for the side of a diff where a component does not exist yet or anymore, so it now writes the empty side itself. A directory that exists without an input reports a render failure there, as the hydrator would. Every kubernetes/components/*/production in platform's origin/main has one of the two inputs, so no current component starts failing. Claude-Session: https://claude.ai/code/session_01N1XYRqYewzhQfyJcM9zFjG Signed-off-by: panicboat --- .../kubernetes-hydrate/hydrate-component.sh | 6 ++ scripts/kubernetes-hydrate/render-diff.sh | 5 ++ .../tests/hydrate-component.test.sh | 61 +++++++++++++++++++ .../tests/render-diff.test.sh | 2 + 4 files changed, 74 insertions(+) create mode 100755 scripts/kubernetes-hydrate/tests/hydrate-component.test.sh 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/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/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 From dad88f6866cd12c815af35e5992333902e6d15f7 Mon Sep 17 00:00:00 2001 From: panicboat Date: Sat, 31 Oct 2026 09:20:59 +0900 Subject: [PATCH 2/5] fix: remove the manifests of a deleted component Deleting kubernetes/components/// labels the pull request deploy:, but the resolver returns no helmfile target, so the hydrate job was skipped. kubernetes/manifests/// stayed and Flux kept deploying it. helmfile-targets-group now also lists each environment with an orphan directory, with no services, so the hydrator runs only hydrate-index.sh for it. The job needs both checkouts and runs whenever resolve succeeded, including when no pull request was found and the targets output is empty. deploy-helmfile-hydrate skips an empty list because fromJson('[]') fails the matrix. deploy-helmfile and deployment-summary keep their conditions. The detection reads the default merge ref like the resolve job, so it adds no failure mode for a branch that no longer exists. Claude-Session: https://claude.ai/code/session_01N1XYRqYewzhQfyJcM9zFjG Signed-off-by: panicboat --- ...reusable--label-base-deploy--resolver.yaml | 26 +++++-- .../kubernetes-hydrate/orphan-environments.sh | 19 +++++ .../tests/orphan-environments.test.sh | 74 +++++++++++++++++++ scripts/workflows/test.sh | 60 +++++++++++++++ 4 files changed, 174 insertions(+), 5 deletions(-) create mode 100755 scripts/kubernetes-hydrate/orphan-environments.sh create mode 100755 scripts/kubernetes-hydrate/tests/orphan-environments.test.sh 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/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/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/workflows/test.sh b/scripts/workflows/test.sh index 20ddf11..81336be 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="" From 02f487c22955e97ba31b84ae661fc86dcca624c6 Mon Sep 17 00:00:00 2001 From: panicboat Date: Sat, 31 Oct 2026 09:21:25 +0900 Subject: [PATCH 3/5] fix: build a release image from its tag reusable--release--build.yaml read the config at the tag, but the image builder's checkout had no ref. A manual workflow_dispatch with tag=monolith-v0.9.0 therefore built main's code and overwrote :v0.9.0. The image builder takes an optional ref, whose empty default keeps the label flow's checkout, and the release build passes the tag. Claude-Session: https://claude.ai/code/session_01N1XYRqYewzhQfyJcM9zFjG Signed-off-by: panicboat --- .github/workflows/internal--image-builder.yaml | 6 ++++++ .github/workflows/reusable--release--build.yaml | 2 ++ scripts/workflows/test.sh | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/.github/workflows/internal--image-builder.yaml b/.github/workflows/internal--image-builder.yaml index 4649b6f..bcb3487 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 diff --git a/.github/workflows/reusable--release--build.yaml b/.github/workflows/reusable--release--build.yaml index 6bbb814..72a4393 100644 --- a/.github/workflows/reusable--release--build.yaml +++ b/.github/workflows/reusable--release--build.yaml @@ -71,6 +71,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/workflows/test.sh b/scripts/workflows/test.sh index 81336be..5c11a3c 100755 --- a/scripts/workflows/test.sh +++ b/scripts/workflows/test.sh @@ -193,6 +193,18 @@ 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_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]+$' From 54943fa255c7f998dadbe30bb2dfe91c072890af Mon Sep 17 00:00:00 2001 From: panicboat Date: Sat, 31 Oct 2026 09:22:10 +0900 Subject: [PATCH 4/5] fix: parse release tags with the service name rule The release build parsed tags with [a-z-]+ and release-service.sh used [a-z0-9-]+, while a service name is [a-zA-Z0-9_-]+ in deploy-actions. route53 and oauth2-proxy could deploy but not build. release-service.sh now holds the one rule for both modes, and the release build calls it, which needs the shared workflows checkout that the plan and deploy workflows use. The version is what follows "-" in the validated tag, so it needs no second rule, and greedy matching keeps the hyphens of a name such as github-oidc-auth. The ::error:: for a bad tag now comes from the script. The test that expected no checkout in detect-component now expects only the shared workflows checkout, which keeps its intent that the consumer tree is not checked out. Claude-Session: https://claude.ai/code/session_01N1XYRqYewzhQfyJcM9zFjG Signed-off-by: panicboat --- .../workflows/reusable--release--build.yaml | 19 +++++---- scripts/terragrunt-release/release-service.sh | 7 +++- scripts/terragrunt-release/test.sh | 20 ++++++++++ scripts/workflows/test.sh | 39 ++++++++++++++++++- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/.github/workflows/reusable--release--build.yaml b/.github/workflows/reusable--release--build.yaml index 72a4393..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. 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 5c11a3c..c2fac46 100755 --- a/scripts/workflows/test.sh +++ b/scripts/workflows/test.sh @@ -205,6 +205,42 @@ test_release_build_checks_out_the_release_tag() { assert_eq 'null' "$(yq '.jobs["deploy-container"].with.ref' "${resolver}")" } +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]+$' @@ -218,7 +254,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}")" From f30aaa0503889f347bfd8ee99cf96de4fd129e9e Mon Sep 17 00:00:00 2001 From: panicboat Date: Sat, 31 Oct 2026 09:24:42 +0900 Subject: [PATCH 5/5] fix: keep manual release rebuilds from moving the latest tag A release build now checks out its tag, so a manual workflow_dispatch of an old tag from main would have pushed the old code as :latest. The latest tag now also requires an empty ref. A release event passes a ref as well, and is_default_branch is already false there because its ref is the tag, so its tags do not change. The sha and actor tags stay, because the release event's sha tag names the commit it builds. Claude-Session: https://claude.ai/code/session_01N1XYRqYewzhQfyJcM9zFjG Signed-off-by: panicboat --- .github/workflows/internal--image-builder.yaml | 2 +- scripts/workflows/test.sh | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/internal--image-builder.yaml b/.github/workflows/internal--image-builder.yaml index bcb3487..f6992c6 100644 --- a/.github/workflows/internal--image-builder.yaml +++ b/.github/workflows/internal--image-builder.yaml @@ -86,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/scripts/workflows/test.sh b/scripts/workflows/test.sh index c2fac46..24924e7 100755 --- a/scripts/workflows/test.sh +++ b/scripts/workflows/test.sh @@ -205,6 +205,15 @@ test_release_build_checks_out_the_release_tag() { 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"]'