From 41cfe0688cc910183e282ed22d358729f65ba58b Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 03:30:08 +0900 Subject: [PATCH 01/12] feat: add the terragrunt release scripts Move the release scripts that panicboat/monorepo and panicboat/platform each carry into the shared repository. Base them on the monorepo version, which reads the stack path templates, so both layouts resolve to targets. Derive the release-please package and exclude-paths from the path template, because cutting working_directory at /infrastructure/ fails on the aws/{service}/{environment} layout. Reject a terragrunt production environment whose apply_trigger is not release. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .../check-release-configs.sh | 110 +++++ scripts/terragrunt-release/release-service.sh | 29 ++ scripts/terragrunt-release/release-targets.sh | 84 ++++ scripts/terragrunt-release/test.sh | 429 ++++++++++++++++++ .../validate-apply-trigger.sh | 23 + 5 files changed, 675 insertions(+) create mode 100755 scripts/terragrunt-release/check-release-configs.sh create mode 100755 scripts/terragrunt-release/release-service.sh create mode 100755 scripts/terragrunt-release/release-targets.sh create mode 100755 scripts/terragrunt-release/test.sh create mode 100755 scripts/terragrunt-release/validate-apply-trigger.sh diff --git a/scripts/terragrunt-release/check-release-configs.sh b/scripts/terragrunt-release/check-release-configs.sh new file mode 100755 index 0000000..b9ca220 --- /dev/null +++ b/scripts/terragrunt-release/check-release-configs.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +config_path="${1:-workflow-config.yaml}" + +# Excluded targets still need a release-please config, so the check starts from the targets before exclusion. +targets="$(bash "${script_dir}/release-targets.sh" --all "${config_path}")" + +config="$(yq -o=json '.' "${config_path}")" + +failed=0 + +report() { + echo "::error::$*" >&2 + failed=1 +} + +incomplete="$(jq -r ' + .[] | select(.excluded | not) | . as $target + | ("aws_region", "iam_role_plan", "iam_role_apply") as $field + | select(($target[$field] // "") == "") + | "::error::release target \($target.working_directory) has no \($field)" +' <<<"${targets}")" +if [ -n "${incomplete}" ]; then + echo "${incomplete}" >&2 + failed=1 +fi + +# A package ends at {service} because what follows it differs per repository layout. +release_packages="$(jq -r ' + map(. as $target | [ + $target.service, + ($target.working_directory | split("/")[:($target.pattern | split("/") | index("{service}") + 1)] | join("/")) + ]) + | unique[] | @tsv +' <<<"${targets}")" + +while IFS=$'\t' read -r service package; do + [ -n "${service}" ] || continue + config_file="${package}/release-please-config.json" + manifest_file="${package}/release-please-manifest.json" + + if [ ! -f "${config_file}" ]; then + report "${config_file} is missing but ${service} has a release environment" + continue + fi + + component="$(jq -r --arg package "${package}" '.packages[$package].component // ""' "${config_file}")" + if [ "${component}" != "${service}" ]; then + report "${package}: component must be ${service} (got '${component}')" + fi + + if [ ! -f "${manifest_file}" ] || ! jq -e --arg package "${package}" 'has($package)' "${manifest_file}" >/dev/null; then + report "${manifest_file} must have the key ${package}" + fi +done <<<"${release_packages}" + +components="" +config_files="$(git ls-files '*/release-please-config.json')" +while IFS= read -r config_file; do + [ -n "${config_file}" ] || continue + packages="$(jq -r '.packages // {} | to_entries[] | [.key, (.value.component // "")] | @tsv' "${config_file}")" + while IFS=$'\t' read -r package component; do + [ -n "${package}" ] || continue + if [ -n "${component}" ]; then + components+="${component}"$'\t'"${package}"$'\n' + fi + expected_exclude_paths="$(jq -c --arg package "${package}" ' + ($package | split("/")) as $package_segments + | [ + .stacks[] | select(.name == "terragrunt") + | [.environments | to_entries[] | select(.value.apply_trigger != "release") | .key] as $environments + | .paths[] | select(contains("{service}") and contains("{environment}")) + | split("/") as $segments + | ($segments | index("{service}") + 1) as $depth + | select($depth == ($package_segments | length)) + | select([range($depth) | . as $i | ($segments[$i] | test("^\\{[a-z_][a-z0-9_]*\\}$")) or $segments[$i] == $package_segments[$i]] | all) + | [ + $environments[] as $environment + | [range($segments | length) | . as $i | if $i < $depth then $package_segments[$i] elif $segments[$i] == "{environment}" then $environment else $segments[$i] end] + | join("/") + ] + ] + | if length == 0 then null else add end + ' <<<"${config}")" + if [ "${expected_exclude_paths}" = "null" ]; then + continue + fi + actual_exclude_paths="$(jq -c --arg package "${package}" '.packages[$package]["exclude-paths"]' "${config_file}")" + if ! jq -ne --argjson expected "${expected_exclude_paths}" --argjson actual "${actual_exclude_paths}" ' + ($actual | type) == "array" and ($actual | sort) == ($expected | sort) + ' >/dev/null; then + report "${package}: exclude-paths must be ${expected_exclude_paths} (got ${actual_exclude_paths})" + fi + done <<<"${packages}" +done <<<"${config_files}" + +# A release tag names only the component, so a component shared by two packages would release both. +duplicates="$(jq -R -s -r ' + split("\n") | map(select(. != "") | split("\t")) | group_by(.[0])[] + | select(length > 1) + | "::error::component \(.[0][0]) is used by more than one package: \(map(.[1]) | join(" "))" +' <<<"${components}")" +if [ -n "${duplicates}" ]; then + echo "${duplicates}" >&2 + failed=1 +fi + +exit "${failed}" diff --git a/scripts/terragrunt-release/release-service.sh b/scripts/terragrunt-release/release-service.sh new file mode 100755 index 0000000..27ebbb9 --- /dev/null +++ b/scripts/terragrunt-release/release-service.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-}" +value="${2:-}" + +case "${mode}" in + --tag) + pattern='^([a-z0-9-]+)-v[0-9]+\.[0-9]+\.[0-9]+$' + expected='-vX.Y.Z' + ;; + --branch) + pattern='^release-please--branches--main--components--([a-z0-9-]+)$' + expected='release-please--branches--main--components--' + ;; + *) + echo "::error::usage: release-service.sh --tag | --branch " >&2 + exit 1 + ;; +esac + +if [[ ! "${value}" =~ ${pattern} ]]; then + shown="${value//$'\n'/ }" + shown="${shown//$'\r'/ }" + echo "::error::'${shown}' does not match ${expected}" >&2 + exit 1 +fi + +echo "${BASH_REMATCH[1]}" diff --git a/scripts/terragrunt-release/release-targets.sh b/scripts/terragrunt-release/release-targets.sh new file mode 100755 index 0000000..86fdad3 --- /dev/null +++ b/scripts/terragrunt-release/release-targets.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +service="${1:?usage: release-targets.sh [config_path] | release-targets.sh --all [config_path]}" +config_path="${2:-workflow-config.yaml}" + +all=false +if [ "${service}" = "--all" ]; then + all=true +fi + +config="$(yq -o=json '.' "${config_path}")" + +# A path without {service} or {environment} cannot be tied to a release, so it never yields a target. +patterns="$(jq -r --arg service "${service}" --argjson all "${all}" ' + .stacks | to_entries[] + | select(.value.name == "terragrunt") + | .key as $stack + | .value as $value + | ($value.environments | to_entries[] | select(.value.apply_trigger == "release") | .key) as $environment + | $value.paths[] + | select(contains("{service}") and contains("{environment}")) + | [$stack, ., $environment, (gsub("\\{environment\\}"; $environment) | if $all then . else gsub("\\{service\\}"; $service) end)] + | @tsv +' <<<"${config}")" + +placeholder='^\{[a-z_][a-z0-9_]*\}$' +shopt -s nullglob + +# Walks one segment at a time so that a literal segment is never expanded as a glob. +find_directories() { + local segments segment dir child dirs="." next + IFS=/ read -r -a segments <<<"$1" + for segment in "${segments[@]}"; do + next="" + while IFS= read -r dir; do + [ -n "${dir}" ] || continue + if [[ "${segment}" =~ ${placeholder} ]]; then + for child in "${dir}"/*/; do + next+="${child%/}"$'\n' + done + elif [ -d "${dir}/${segment}" ]; then + next+="${dir}/${segment}"$'\n' + fi + done <<<"${dirs}" + dirs="${next}" + done + printf '%s' "${dirs}" +} + +matches="" +while IFS=$'\t' read -r stack pattern environment search; do + [ -n "${pattern}" ] || continue + while IFS= read -r directory; do + [ -n "${directory}" ] || continue + matches+="${stack}"$'\t'"${pattern}"$'\t'"${environment}"$'\t'"${directory#./}"$'\n' + done <<<"$(find_directories "${search}")" +done <<<"${patterns}" + +jq -R -s -c --argjson config "${config}" --argjson all "${all}" ' + def capture_regex: + "^" + (split("/") | map( + if test("^\\{[a-z_][a-z0-9_]*\\}$") then "(?<\(.[1:-1])>[^/]+)" + else gsub("(?[.^$|?*+()\\[\\]{}\\\\])"; "\\\(.c)") end + ) | join("/")) + "$"; + [ + split("\n")[] | select(. != "") | split("\t") + | . as [$stack, $pattern, $environment, $directory] + | $config.stacks[$stack | tonumber] as $stack_config + | $stack_config.environments[$environment] as $attributes + | ($directory | capture($pattern | capture_regex)) as $values + | { + service: $values.service, + environment: $environment, + working_directory: $directory, + aws_region: $attributes.aws_region, + iam_role_plan: $attributes.iam_role_plan, + iam_role_apply: $attributes.iam_role_apply, + pattern: $pattern, + excluded: any(($stack_config.exclude // [])[]; to_entries | all(.[]; . as $pair | $values | has($pair.key) and .[$pair.key] == $pair.value)) + } + ] + | if $all then . else map(select(.excluded | not) | del(.excluded, .pattern)) end +' <<<"${matches}" diff --git a/scripts/terragrunt-release/test.sh b/scripts/terragrunt-release/test.sh new file mode 100755 index 0000000..44072f5 --- /dev/null +++ b/scripts/terragrunt-release/test.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +work_root="$(mktemp -d)" +trap 'rm -rf "${work_root}"' EXIT + +last_stderr="" + +assert_eq() { + local expected="$1" actual="$2" + if [ "${expected}" != "${actual}" ]; then + echo " expected: ${expected}" >&2 + echo " actual: ${actual}" >&2 + exit 1 + fi +} + +assert_fails() { + local stderr_file="${work_root}/stderr" + if "$@" 2>"${stderr_file}"; then + echo " expected a non-zero exit: $*" >&2 + exit 1 + fi + last_stderr="$(cat "${stderr_file}")" +} + +assert_stderr_contains() { + local needle="$1" + if [[ "${last_stderr}" != *"${needle}"* ]]; then + echo " stderr does not contain: ${needle}" >&2 + echo " stderr: ${last_stderr}" >&2 + exit 1 + fi +} + +test_validate_accepts_merge_and_release() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + environments: + develop: + apply_trigger: merge + production: + apply_trigger: release +YAML + bash "${script_dir}/validate-apply-trigger.sh" +} + +test_validate_rejects_missing_trigger() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + environments: + develop: + apply_trigger: merge + production: + aws_region: ap-northeast-1 +YAML + assert_fails bash "${script_dir}/validate-apply-trigger.sh" + assert_stderr_contains "environments.production.apply_trigger" +} + +test_validate_rejects_unknown_value() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + environments: + production: + apply_trigger: relase +YAML + assert_fails bash "${script_dir}/validate-apply-trigger.sh" + assert_stderr_contains "(got 'relase')" +} + +test_validate_ignores_non_terragrunt_stacks() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: container + paths: + - "dystopia/{service}" + - name: terragrunt + environments: + develop: + apply_trigger: merge + - name: kubernetes + paths: + - "dystopia/{service}/kubernetes/overlays/{environment}" +YAML + bash "${script_dir}/validate-apply-trigger.sh" +} + +test_release_service_parses_tag() { + assert_eq "monolith" "$(bash "${script_dir}/release-service.sh" --tag monolith-v1.2.3)" +} + +test_release_service_rejects_root_tag() { + assert_fails bash "${script_dir}/release-service.sh" --tag v0.1.0 + assert_stderr_contains "::error::" +} + +test_release_service_rejects_flux_tag() { + assert_fails bash "${script_dir}/release-service.sh" --tag kubernetes-production + assert_stderr_contains "::error::" +} + +test_release_service_parses_branch() { + assert_eq "pennyworth" "$(bash "${script_dir}/release-service.sh" --branch release-please--branches--main--components--pennyworth)" +} + +test_release_service_rejects_root_branch() { + assert_fails bash "${script_dir}/release-service.sh" --branch release-please--branches--main + assert_stderr_contains "::error::" +} + +test_release_service_error_is_single_line() { + assert_fails bash "${script_dir}/release-service.sh" --tag $'monolith-v1.2.3\n::warning::x' + assert_stderr_contains "::error::" + assert_eq "1" "$(printf '%s\n' "${last_stderr}" | wc -l | tr -d ' ')" +} + +write_release_config() { + local exclude="${1:-}" + cat > workflow-config.yaml <<'YAML' +stacks: + - name: container + paths: + - "dystopia/{service}" + - "system-components/{service}" + - name: terragrunt + paths: + - "{product}/{service}/infrastructure/aws/{environment}" + environments: + develop: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::111111111111:role/develop-plan + iam_role_apply: arn:aws:iam::111111111111:role/develop-apply + apply_trigger: merge + production: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::222222222222:role/production-plan + iam_role_apply: arn:aws:iam::222222222222:role/production-apply + apply_trigger: release + - name: kubernetes + paths: + - "{product}/{service}/kubernetes/overlays/{environment}" +YAML + if [ -n "${exclude}" ]; then + EXCLUDE="${exclude}" yq -i '(.stacks[] | select(.name == "terragrunt") | .exclude) = env(EXCLUDE)' workflow-config.yaml + fi +} + +test_release_targets_lists_release_envs() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/develop dystopia/monolith/infrastructure/aws/production + local expected='[{"service":"monolith","environment":"production","working_directory":"dystopia/monolith/infrastructure/aws/production","aws_region":"ap-northeast-1","iam_role_plan":"arn:aws:iam::222222222222:role/production-plan","iam_role_apply":"arn:aws:iam::222222222222:role/production-apply"}]' + local targets + targets="$(bash "${script_dir}/release-targets.sh" monolith)" + assert_eq "${expected}" "${targets}" +} + +test_release_targets_finds_other_products() { + write_release_config + mkdir -p system-components/pennyworth/infrastructure/aws/production dystopia/monolith/infrastructure/aws/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" pennyworth)" + assert_eq "1" "$(jq 'length' <<<"${targets}")" + assert_eq "system-components/pennyworth/infrastructure/aws/production" "$(jq -r '.[0].working_directory' <<<"${targets}")" +} + +test_release_targets_returns_empty_without_dirs() { + write_release_config + mkdir -p dystopia/frontend + local targets + targets="$(bash "${script_dir}/release-targets.sh" frontend)" + assert_eq "[]" "${targets}" +} + +test_release_targets_skips_excluded_envs() { + write_release_config '[{environment: production}]' + mkdir -p dystopia/monolith/infrastructure/aws/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" monolith)" + assert_eq "[]" "${targets}" +} + +test_release_targets_honors_product_scoped_exclude() { + write_release_config '[{product: system-components, environment: production}]' + mkdir -p system-components/pennyworth/infrastructure/aws/production dystopia/monolith/infrastructure/aws/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" pennyworth)" + assert_eq "[]" "${targets}" + targets="$(bash "${script_dir}/release-targets.sh" monolith)" + assert_eq "dystopia/monolith/infrastructure/aws/production" "$(jq -r '.[0].working_directory' <<<"${targets}")" +} + +test_release_targets_all_keeps_excluded_targets() { + write_release_config '[{service: monolith}]' + mkdir -p dystopia/monolith/infrastructure/aws/production system-components/pennyworth/infrastructure/aws/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" --all)" + assert_eq "monolith:true pennyworth:false" "$(jq -r 'map("\(.service):\(.excluded)") | sort | join(" ")' <<<"${targets}")" +} + +write_service_release_config() { + local package="$1" component="${2:-${1##*/}}" exclude_paths="${3:-[\"$1/infrastructure/aws/develop\"]}" + mkdir -p "${package}" + cat > "${package}/release-please-config.json" < "${package}/release-please-manifest.json" +} + +check_configs() { + git init -q + git add -A + bash "${script_dir}/check-release-configs.sh" "$@" +} + +test_check_configs_accepts_matching_set() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith + write_service_release_config dystopia/frontend + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_configs_rejects_missing_config() { + write_release_config + mkdir -p system-components/pennyworth/infrastructure/aws/production + assert_fails check_configs + assert_stderr_contains "system-components/pennyworth/release-please-config.json" +} + +test_check_configs_rejects_wrong_component() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith frontend + assert_fails check_configs + assert_stderr_contains "dystopia/monolith: component must be monolith (got 'frontend')" +} + +test_check_configs_rejects_manifest_without_package() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith + echo '{}' > dystopia/monolith/release-please-manifest.json + assert_fails check_configs + assert_stderr_contains "dystopia/monolith/release-please-manifest.json" + rm dystopia/monolith/release-please-manifest.json + assert_fails check_configs + assert_stderr_contains "dystopia/monolith/release-please-manifest.json" +} + +test_check_configs_rejects_incomplete_release_env() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + paths: + - "{product}/{service}/infrastructure/aws/{environment}" + environments: + production: + apply_trigger: release +YAML + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith monolith '[]' + assert_fails check_configs + assert_stderr_contains "dystopia/monolith/infrastructure/aws/production" + assert_stderr_contains "aws_region" + assert_stderr_contains "iam_role_plan" + assert_stderr_contains "iam_role_apply" +} + +test_check_configs_rejects_missing_exclude_paths() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith + jq -c 'del(.packages["dystopia/monolith"]["exclude-paths"])' dystopia/monolith/release-please-config.json > config.tmp + mv config.tmp dystopia/monolith/release-please-config.json + assert_fails check_configs + assert_stderr_contains '::error::dystopia/monolith: exclude-paths must be ["dystopia/monolith/infrastructure/aws/develop"] (got null)' +} + +test_check_configs_accepts_exclude_paths_in_any_order() { + write_release_config + yq -i '(.stacks[] | select(.name == "terragrunt") | .environments.staging) = {"apply_trigger": "merge"}' workflow-config.yaml + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith monolith '["dystopia/monolith/infrastructure/aws/staging","dystopia/monolith/infrastructure/aws/develop"]' + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_configs_checks_configs_without_infra() { + write_release_config + write_service_release_config dystopia/frontend frontend '["dystopia/frontend/develop"]' + assert_fails check_configs + assert_stderr_contains 'dystopia/frontend: exclude-paths must be ["dystopia/frontend/infrastructure/aws/develop"]' +} + +test_check_configs_requires_config_for_excluded_env() { + write_release_config '[{environment: production}]' + mkdir -p dystopia/monolith/infrastructure/aws/production + assert_fails check_configs + assert_stderr_contains "dystopia/monolith/release-please-config.json" + write_service_release_config dystopia/monolith + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_configs_ignores_packages_outside_path_templates() { + write_release_config + write_service_release_config tools tools '[]' + write_service_release_config x/y/z z '[]' + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_configs_ignores_untracked_configs() { + write_release_config + write_service_release_config dystopia/frontend + git init -q + git add -A + write_service_release_config node_modules/pkg pkg '[]' + assert_eq "" "$(bash "${script_dir}/check-release-configs.sh" 2>&1)" +} + +test_check_configs_rejects_duplicate_service() { + write_release_config + write_service_release_config dystopia/monolith + write_service_release_config system-components/monolith + assert_fails check_configs + assert_stderr_contains "component monolith is used by more than one package: dystopia/monolith system-components/monolith" +} + +write_service_root_release_config() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + paths: + - "aws/{service}/{environment}" + - "github/{service}/{environment}" + environments: + master: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::111111111111:role/master-plan + iam_role_apply: arn:aws:iam::111111111111:role/master-apply + apply_trigger: merge + develop: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::333333333333:role/develop-plan + iam_role_apply: arn:aws:iam::333333333333:role/develop-apply + apply_trigger: merge + production: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::222222222222:role/production-plan + iam_role_apply: arn:aws:iam::222222222222:role/production-apply + apply_trigger: release +YAML + mkdir -p aws/vpc/master aws/vpc/develop aws/vpc/production +} + +test_check_release_configs_accepts_service_root_package() { + write_service_root_release_config + write_service_release_config aws/vpc vpc '["aws/vpc/master", "aws/vpc/develop"]' + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_release_configs_rejects_missing_exclude_path() { + write_service_root_release_config + write_service_release_config aws/vpc vpc '["aws/vpc/master"]' + assert_fails check_configs + assert_stderr_contains "aws/vpc/develop" +} + +test_check_release_configs_accepts_product_service_package() { + write_release_config + write_service_release_config dystopia/frontend frontend '["dystopia/frontend/infrastructure/aws/develop"]' + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_release_configs_derives_exclude_paths_from_the_matching_path_template() { + write_service_root_release_config + write_service_release_config aws/vpc vpc '["aws/vpc/master", "aws/vpc/develop"]' + write_service_release_config github/repository repository '[]' + assert_fails check_configs + assert_stderr_contains 'github/repository: exclude-paths must be ["github/repository/master","github/repository/develop"]' +} + +test_release_targets_lists_service_root_release_envs() { + write_service_root_release_config + local expected='[{"service":"vpc","environment":"production","working_directory":"aws/vpc/production","aws_region":"ap-northeast-1","iam_role_plan":"arn:aws:iam::222222222222:role/production-plan","iam_role_apply":"arn:aws:iam::222222222222:role/production-apply"}]' + assert_eq "${expected}" "$(bash "${script_dir}/release-targets.sh" vpc)" +} + +test_release_targets_all_reports_the_path_template() { + write_service_root_release_config + mkdir -p github/repository/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" --all)" + assert_eq "aws/{service}/{environment} github/{service}/{environment}" "$(jq -r 'map(.pattern) | sort | join(" ")' <<<"${targets}")" +} + +test_validate_apply_trigger_rejects_merge_production() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + environments: + develop: + apply_trigger: merge + production: + apply_trigger: merge +YAML + assert_fails bash "${script_dir}/validate-apply-trigger.sh" + assert_stderr_contains "production" +} + +failed=0 +for test_name in $(compgen -A function test_); do + fixture="$(mktemp -d "${work_root}/fixture.XXXXXX")" + set +e + (set -e; cd "${fixture}"; "${test_name}") + status=$? + set -e + if [ "${status}" -eq 0 ]; then + echo "PASS: ${test_name}" + else + echo "FAIL: ${test_name}" + failed=1 + fi +done + +exit "${failed}" diff --git a/scripts/terragrunt-release/validate-apply-trigger.sh b/scripts/terragrunt-release/validate-apply-trigger.sh new file mode 100755 index 0000000..12c06fe --- /dev/null +++ b/scripts/terragrunt-release/validate-apply-trigger.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +config_path="${1:-workflow-config.yaml}" + +errors="$(yq -o=json '.' "${config_path}" | jq -r --arg q "'" ' + .stacks | to_entries[] + | select(.value.name == "terragrunt") + | .key as $index + | .value.environments | to_entries[] + | if .key == "production" and .value.apply_trigger != "release" then + "::error::stacks[\($index)].environments.production.apply_trigger must be release (got \($q)\(.value.apply_trigger)\($q))" + elif .value.apply_trigger | IN("merge", "release") | not then + "::error::stacks[\($index)].environments.\(.key).apply_trigger must be merge or release (got \($q)\(.value.apply_trigger)\($q))" + else + empty + end +')" + +if [ -n "${errors}" ]; then + echo "${errors}" >&2 + exit 1 +fi From 40c4308add43db950ca23e41f8b00501db6f91e1 Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 03:37:45 +0900 Subject: [PATCH 02/12] feat: add the kubernetes hydrate, docs sync check, and release-please scripts The kubernetes-hydrate scripts and their tests move from platform unchanged. decide.sh moves from monorepo and takes the paths it watches from SCOPE_PATTERN, which is required. find-configs.sh lists the directories that hold a release-please-config.json, so a workflow can build a matrix from it. test-scripts.yaml runs every script test on pull requests and on pushes to main. aqua.yaml pins the yq and kustomize that the kubernetes-hydrate tests run against. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .github/workflows/test-scripts.yaml | 33 ++++++ aqua.yaml | 7 ++ scripts/docs-sync-check/decide.sh | 65 +++++++++++ scripts/docs-sync-check/decide.test.sh | 65 +++++++++++ .../kubernetes-hydrate/hydrate-component.sh | 43 +++++++ scripts/kubernetes-hydrate/hydrate-index.sh | 50 +++++++++ scripts/kubernetes-hydrate/render-diff.sh | 77 +++++++++++++ .../summarize-manifest-diff.sh | 106 ++++++++++++++++++ .../tests/fixtures/identical/expected.md | 11 ++ .../tests/fixtures/identical/manifest.yaml | 4 + .../tests/fixtures/invalid/manifest.yaml | 2 + .../tests/fixtures/summarize/base.yaml | 62 ++++++++++ .../tests/fixtures/summarize/expected.md | 29 +++++ .../tests/fixtures/summarize/head.yaml | 63 +++++++++++ .../tests/render-diff.test.sh | 88 +++++++++++++++ .../tests/summarize-manifest-diff.test.sh | 35 ++++++ scripts/release-please/find-configs.sh | 5 + scripts/release-please/find-configs.test.sh | 47 ++++++++ 18 files changed, 792 insertions(+) create mode 100644 .github/workflows/test-scripts.yaml create mode 100644 aqua.yaml create mode 100755 scripts/docs-sync-check/decide.sh create mode 100755 scripts/docs-sync-check/decide.test.sh create mode 100755 scripts/kubernetes-hydrate/hydrate-component.sh create mode 100755 scripts/kubernetes-hydrate/hydrate-index.sh create mode 100755 scripts/kubernetes-hydrate/render-diff.sh create mode 100755 scripts/kubernetes-hydrate/summarize-manifest-diff.sh create mode 100644 scripts/kubernetes-hydrate/tests/fixtures/identical/expected.md create mode 100644 scripts/kubernetes-hydrate/tests/fixtures/identical/manifest.yaml create mode 100644 scripts/kubernetes-hydrate/tests/fixtures/invalid/manifest.yaml create mode 100644 scripts/kubernetes-hydrate/tests/fixtures/summarize/base.yaml create mode 100644 scripts/kubernetes-hydrate/tests/fixtures/summarize/expected.md create mode 100644 scripts/kubernetes-hydrate/tests/fixtures/summarize/head.yaml create mode 100755 scripts/kubernetes-hydrate/tests/render-diff.test.sh create mode 100755 scripts/kubernetes-hydrate/tests/summarize-manifest-diff.test.sh create mode 100755 scripts/release-please/find-configs.sh create mode 100755 scripts/release-please/find-configs.test.sh diff --git a/.github/workflows/test-scripts.yaml b/.github/workflows/test-scripts.yaml new file mode 100644 index 0000000..2145863 --- /dev/null +++ b/.github/workflows/test-scripts.yaml @@ -0,0 +1,33 @@ +name: Test Scripts + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test scripts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Run script tests + run: | + status=0 + for test_script in scripts/*/test.sh scripts/*/*.test.sh scripts/kubernetes-hydrate/tests/*.test.sh; do + echo "::group::${test_script}" + bash "${test_script}" || status=1 + echo "::endgroup::" + done + exit "${status}" diff --git a/aqua.yaml b/aqua.yaml new file mode 100644 index 0000000..b3f08e0 --- /dev/null +++ b/aqua.yaml @@ -0,0 +1,7 @@ +registries: + - type: standard + ref: v4.311.0 # renovate: github_release aquaproj/aqua-registry + +packages: + - name: kubernetes-sigs/kustomize@kustomize/v5.6.0 + - name: mikefarah/yq@v4.54.1 diff --git a/scripts/docs-sync-check/decide.sh b/scripts/docs-sync-check/decide.sh new file mode 100755 index 0000000..c6f6fac --- /dev/null +++ b/scripts/docs-sync-check/decide.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Read stdin before any early exit so the upstream command under pipefail does not die of SIGPIPE. +changed_files="$(cat)" +scope_pattern="${SCOPE_PATTERN:?SCOPE_PATTERN is required}" +is_approved="$APPROVED" +remove_label=false + +emit() { + echo "mode=$1" + echo "remove_label=${remove_label}" +} + +case "$EVENT_ACTION" in + labeled | unlabeled) + if [ "$EVENT_LABEL" != "docs-check-approved" ]; then + emit ignore + exit 0 + fi + ;; +esac + +if [ "$AUTHOR_TYPE" = "Bot" ]; then + emit skip-bot + exit 0 +fi + +if [ "$EVENT_ACTION" = "edited" ] && [ "$BODY_OR_BASE_CHANGED" != "true" ]; then + emit ignore + exit 0 +fi + +case "$EVENT_ACTION" in + labeled) + # Approval needs an existing verdict on the commit, since a label set at PR creation can cancel the opened run. + if [ "$is_approved" = "true" ] && [ "$PRIOR_STATE" != "" ] && [ "$PRIOR_STATE" != "pending" ]; then + emit approved + exit 0 + fi + remove_label="$is_approved" + is_approved=false + ;; + unlabeled) + is_approved=false + ;; + synchronize | opened) + if [ "$is_approved" = "true" ]; then + is_approved=false + remove_label=true + fi + ;; +esac + +if [ "$is_approved" = "true" ]; then + emit ignore + exit 0 +fi + +if ! grep -qE "$scope_pattern" <<<"$changed_files"; then + emit skip-unchanged + exit 0 +fi + +emit judge diff --git a/scripts/docs-sync-check/decide.test.sh b/scripts/docs-sync-check/decide.test.sh new file mode 100755 index 0000000..c86e4c1 --- /dev/null +++ b/scripts/docs-sync-check/decide.test.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +decide="${script_dir}/decide.sh" +failed=0 + +check() { + local name="$1" action="$2" label="$3" approved="$4" author="$5" changed="$6" prior="$7" files="$8" expected_mode="$9" expected_remove="${10}" scope="${11:-^dystopia/}" + local expected actual + expected="$(printf 'mode=%s\nremove_label=%s' "$expected_mode" "$expected_remove")" + actual="$(printf '%s' "$files" | SCOPE_PATTERN="$scope" EVENT_ACTION="$action" EVENT_LABEL="$label" APPROVED="$approved" AUTHOR_TYPE="$author" BODY_OR_BASE_CHANGED="$changed" PRIOR_STATE="$prior" bash "$decide" 2>&1)" + if [ "$actual" = "$expected" ]; then + echo "PASS ${name}" + else + echo "FAIL ${name}" + echo " expected: ${expected//$'\n'/ | }" + echo " actual: ${actual//$'\n'/ | }" + failed=1 + fi +} + +check ignores_other_labels labeled deploy:frontend false User false '' $'dystopia/a.rb' ignore false +check ignores_other_labels_even_when_approved labeled deploy:frontend true User false '' $'dystopia/a.rb' ignore false +check ignores_other_label_removed unlabeled deploy:frontend false User false '' $'dystopia/a.rb' ignore false +check skips_bots opened '' false Bot false '' $'dystopia/a.rb' skip-bot false +check skips_without_dystopia_changes opened '' false User false '' $'platform/a.tf' skip-unchanged false +check skips_empty_diff opened '' false User false '' '' skip-unchanged false +check judges_dystopia_changes opened '' false User false '' $'README.md\ndystopia/docs/x.md' judge false +check does_not_match_prefix_only opened '' false User false '' $'dystopia-old/a.rb' skip-unchanged false +check passes_on_approval_label_after_failure labeled docs-check-approved true User false failure $'dystopia/a.rb' approved false +check passes_on_approval_label_after_success labeled docs-check-approved true User false success $'dystopia/a.rb' approved false +check approval_label_before_any_verdict_rejudges labeled docs-check-approved true User false '' $'dystopia/a.rb' judge true +check approval_label_while_judging_rejudges labeled docs-check-approved true User false pending $'dystopia/a.rb' judge true +check approval_label_already_removed_rejudges labeled docs-check-approved false User false failure $'dystopia/a.rb' judge false +check approval_label_before_any_verdict_without_dystopia labeled docs-check-approved true User false '' $'platform/a.tf' skip-unchanged true +check ignores_edit_while_approved edited '' true User true '' $'dystopia/a.rb' ignore false +check ignores_reopen_while_approved reopened '' true User false '' $'dystopia/a.rb' ignore false +check ignores_title_only_edit edited '' false User false '' $'dystopia/a.rb' ignore false +check judges_body_edit edited '' false User true '' $'dystopia/a.rb' judge false +check rejudges_when_approval_removed unlabeled docs-check-approved false User false '' $'dystopia/a.rb' judge false +check opened_with_approval_rejudges opened '' true User false '' $'dystopia/a.rb' judge true +check push_after_approval_rejudges synchronize '' true User false '' $'dystopia/a.rb' judge true +check push_after_approval_without_dystopia synchronize '' true User false '' $'platform/a.tf' skip-unchanged true +check skips_changes_outside_the_scope_pattern opened '' false User false '' $'dystopia/a.rb' skip-unchanged false '^platform/' +check judges_changes_inside_the_scope_pattern opened '' false User false '' $'platform/a.tf' judge false '^platform/' + +check_requires_scope_pattern() { + local name="$1" actual + if actual="$(printf '%s' $'dystopia/a.rb' | env -u SCOPE_PATTERN EVENT_ACTION=opened EVENT_LABEL='' APPROVED=false AUTHOR_TYPE=Bot BODY_OR_BASE_CHANGED=false PRIOR_STATE='' bash "$decide" 2>&1)"; then + echo "FAIL ${name}" + echo " expected a non-zero exit, actual output: ${actual//$'\n'/ | }" + failed=1 + elif [[ "$actual" == *SCOPE_PATTERN* ]]; then + echo "PASS ${name}" + else + echo "FAIL ${name}" + echo " expected stderr to name SCOPE_PATTERN, actual: ${actual//$'\n'/ | }" + failed=1 + fi +} + +check_requires_scope_pattern rejects_missing_scope_pattern_even_when_the_event_exits_early + +exit "$failed" diff --git a/scripts/kubernetes-hydrate/hydrate-component.sh b/scripts/kubernetes-hydrate/hydrate-component.sh new file mode 100755 index 0000000..1303421 --- /dev/null +++ b/scripts/kubernetes-hydrate/hydrate-component.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +component="${1:?component name required}" +env="${2:?environment name required}" + +cd "$(git rev-parse --show-toplevel)" + +component_dir="kubernetes/components/${component}/${env}" +out_dir="kubernetes/manifests/${env}/${component}" + +# 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 + echo "hydrate-component.sh: ${env_hcl} not found; cannot determine cluster version" >&2 + exit 1 +fi +kube_version=$(sed -n 's/^[[:space:]]*cluster_version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "${env_hcl}") +if [ -z "${kube_version}" ]; then + echo "hydrate-component.sh: cluster_version not set in ${env_hcl}" >&2 + exit 1 +fi + +mkdir -p "${out_dir}" +: > "${out_dir}/manifest.yaml" + +if [ -f "${component_dir}/helmfile.yaml" ]; then + helmfile -f "${component_dir}/helmfile.yaml" -e "${env}" template \ + --include-crds --skip-tests --kube-version "${kube_version}" >> "${out_dir}/manifest.yaml" +fi + +if [ -d "${component_dir}/kustomization" ]; then + echo "---" >> "${out_dir}/manifest.yaml" + kustomize build "${component_dir}/kustomization" >> "${out_dir}/manifest.yaml" +fi + +printf "resources:\n - manifest.yaml\n" > "${out_dir}/kustomization.yaml" + +if git ls-files --error-unmatch "${out_dir}/manifest.yaml" >/dev/null 2>&1; then + if git diff --quiet -I '^[[:space:]]*(ca\.crt|ca\.key|tls\.crt|tls\.key|caBundle):' -- "${out_dir}/manifest.yaml"; then + git checkout -- "${out_dir}/manifest.yaml" + fi +fi diff --git a/scripts/kubernetes-hydrate/hydrate-index.sh b/scripts/kubernetes-hydrate/hydrate-index.sh new file mode 100755 index 0000000..291dbd4 --- /dev/null +++ b/scripts/kubernetes-hydrate/hydrate-index.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +env="${1:?environment name required}" + +cd "$(git rev-parse --show-toplevel)" + +env_dir="kubernetes/manifests/${env}" + +mkdir -p "${env_dir}/00-namespaces" +: > "${env_dir}/00-namespaces/namespaces.yaml" + +for comp_dir in kubernetes/components/*/"${env}"/; do + [ -d "${comp_dir}" ] || continue + comp_name=$(basename "$(dirname "${comp_dir}")") + if [ -f "kubernetes/components/${comp_name}/${env}/namespace.yaml" ]; then + echo "---" >> "${env_dir}/00-namespaces/namespaces.yaml" + cat "kubernetes/components/${comp_name}/${env}/namespace.yaml" >> "${env_dir}/00-namespaces/namespaces.yaml" + elif [ -f "kubernetes/components/${comp_name}/namespace.yaml" ]; then + echo "---" >> "${env_dir}/00-namespaces/namespaces.yaml" + cat "kubernetes/components/${comp_name}/namespace.yaml" >> "${env_dir}/00-namespaces/namespaces.yaml" + fi +done + +printf "resources:\n - namespaces.yaml\n" > "${env_dir}/00-namespaces/kustomization.yaml" + +for dir in "${env_dir}"/*/; do + [ -d "${dir}" ] || continue + name=$(basename "${dir}") + if [ "${name}" = "00-namespaces" ]; then + continue + fi + if [ ! -d "kubernetes/components/${name}/${env}" ]; then + rm -rf "${dir}" + fi +done + +{ + echo "resources:" + echo " - ./00-namespaces" + # Sort by full path in C locale so prefix pairs like opentelemetry-collector precede opentelemetry. + for dir in "${env_dir}"/*/; do + [ -d "${dir}" ] || continue + name=$(basename "${dir}") + [ "${name}" = "00-namespaces" ] && continue + printf '%s\n' "${dir}" + done | LC_ALL=C sort | while IFS= read -r dir; do + echo " - ./$(basename "${dir}")" + done +} > "${env_dir}/kustomization.yaml" diff --git a/scripts/kubernetes-hydrate/render-diff.sh b/scripts/kubernetes-hydrate/render-diff.sh new file mode 100755 index 0000000..1446774 --- /dev/null +++ b/scripts/kubernetes-hydrate/render-diff.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_ref="${1:?base revision required}" +out_dir="${2:?output directory required}" + +cd "$(git rev-parse --show-toplevel)" +script_dir="$(pwd)/.github/scripts/kubernetes-hydrate" +mkdir -p "${out_dir}" +out_dir="$(cd "${out_dir}" && pwd)" +summary="${out_dir}/summary.md" + +components="$(git diff --name-only --no-renames "${base_ref}" HEAD -- kubernetes/components/ | awk -F/ 'NF >= 5 { print $3 "/" $4 }' | sort -u)" +if [ -z "${components}" ]; then + echo "No Kubernetes components changed." > "${summary}" + exit 0 +fi + +work="$(mktemp -d)" +cleanup() { + git worktree remove --force "${work}/base" 2>/dev/null || true # SILENT: the worktree may not exist when setup failed + git worktree remove --force "${work}/head" 2>/dev/null || true # SILENT: the worktree may not exist when setup failed + rm -rf "${work}" +} +trap cleanup EXIT +# Separate worktrees keep hydrate-component.sh from rewriting kubernetes/manifests in this checkout. +git worktree add -q --detach "${work}/base" "${base_ref}" +git worktree add -q --detach "${work}/head" HEAD + +render() { + local side="$1" svc="$2" env="$3" dir="$4" + (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" +} + +: > "${summary}" +for component in ${components}; do + svc="${component%/*}" + env="${component#*/}" + dir="${out_dir}/${env}/${svc}" + { + echo "## ${svc} (${env})" + echo + if [ ! -f "aws/eks/${env}/env.hcl" ]; then + echo "Not rendered: \`${env}\` has no \`aws/eks/${env}/env.hcl\`, so the hydrator never deploys it." + echo + continue + fi + mkdir -p "${dir}" + failed="" + render base "${svc}" "${env}" "${dir}" || failed="base" + render head "${svc}" "${env}" "${dir}" || failed="${failed:+${failed} }head" + if [ -n "${failed}" ]; then + echo "Rendering failed (${failed}):" + echo + echo '```' + for side in ${failed}; do + tail -n 20 "${dir}/${side}.log" + done + echo '```' + else + diff -u "${dir}/base.yaml" "${dir}/head.yaml" > "${dir}/manifest.diff" || true # SILENT: diff exits 1 when the manifests differ + echo "Full diff: \`${env}/${svc}/manifest.diff\`" + echo + if bash "${script_dir}/summarize-manifest-diff.sh" "${dir}/base.yaml" "${dir}/head.yaml" > "${dir}/summary.part" 2> "${dir}/summarize.log"; then + cat "${dir}/summary.part" + else + echo "Summarizing failed; use the full diff:" + echo + echo '```' + tail -n 20 "${dir}/summarize.log" + echo '```' + fi + fi + echo + } >> "${summary}" +done diff --git a/scripts/kubernetes-hydrate/summarize-manifest-diff.sh b/scripts/kubernetes-hydrate/summarize-manifest-diff.sh new file mode 100755 index 0000000..c3f23fa --- /dev/null +++ b/scripts/kubernetes-hydrate/summarize-manifest-diff.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +base="${1:?base manifest required}" +head="${2:?head manifest required}" + +export LC_ALL=C + +image_refs() { + { + yq -N '.. | select(tag == "!!map" and has("image")) | .image | select(tag == "!!str")' "$1" + yq -N '.. | select(tag == "!!str") | select(test("^(--[A-Za-z0-9-]+=)?[a-z0-9.-]+(:[0-9]+)?/[a-z0-9./_-]+:[A-Za-z0-9._-]+(@sha256:[a-f0-9]+)?$")) | sub("^--[A-Za-z0-9-]+=", "")' "$1" + } | sort -u +} + +split_image_refs() { + awk -v side="$1" '{ + ref = $0; digest = ""; tag = ""; repo = ref + at = index(ref, "@") + if (at > 0) { digest = substr(ref, at); ref = substr(ref, 1, at - 1); repo = ref } + n = split(ref, parts, "/"); last = parts[n] + colon = index(last, ":") + if (colon > 0) { tag = substr(last, colon + 1); repo = substr(ref, 1, length(ref) - length(last) + colon - 1) } + tag = tag digest + if (tag == "") tag = "(untagged)" + print repo "\t" side "\t" tag + }' +} + +crd_versions() { + yq -N 'select(.kind == "CustomResourceDefinition") | .metadata.name + "\t" + ([.spec.versions[] | .name + (if .served == false then " (not served)" else "" end) + (if .storage then "*" else "" end)] | join(", "))' "$1" | sort -u +} + +resources() { + yq -N 'select(.kind != null) | .kind + " " + (if .metadata.namespace then .metadata.namespace + "/" else "" end) + .metadata.name' "$1" | sort -u +} + +pair_rows() { + awk -F '\t' '{ + key[$1] = 1 + if ($2 == "base") base[$1] = (base[$1] == "" ? $3 : base[$1] ", " $3) + else head[$1] = (head[$1] == "" ? $3 : head[$1] ", " $3) + } + END { + for (k in key) if (base[k] != head[k]) print k "\t" (base[k] == "" ? "-" : base[k]) "\t" (head[k] == "" ? "-" : head[k]) + }' | sort +} + +print_table() { + local header="$1" rows="$2" + if [ -z "${rows}" ]; then + echo "No changes." + return + fi + echo "| ${header} | Base | Head |" + echo "|---|---|---|" + printf '%s\n' "${rows}" | awk -F '\t' '{ print "| " $1 " | " $2 " | " $3 " |" }' +} + +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT +# Extracting into files first lets set -e catch yq failures that command substitutions and process substitutions would hide. +for side in base head; do + if [ "${side}" = base ]; then manifest="${base}"; else manifest="${head}"; fi + image_refs "${manifest}" | split_image_refs "${side}" > "${tmp}/${side}.images" + crd_versions "${manifest}" | awk -F '\t' -v side="${side}" '{ print $1 "\t" side "\t" $2 }' > "${tmp}/${side}.crds" + resources "${manifest}" > "${tmp}/${side}.resources" +done + +image_rows="$(cat "${tmp}/base.images" "${tmp}/head.images" | pair_rows)" +crd_rows="$(cat "${tmp}/base.crds" "${tmp}/head.crds" | pair_rows)" +added="$(comm -13 "${tmp}/base.resources" "${tmp}/head.resources")" +removed="$(comm -23 "${tmp}/base.resources" "${tmp}/head.resources")" + +echo "### Images" +echo +if [ -n "${image_rows}" ]; then + echo 'Collected from `image` fields and from other string values shaped like `registry/repository:tag` (args, env).' + echo +fi +print_table "Repository" "${image_rows}" +echo +echo "### CustomResourceDefinitions" +echo +if [ -n "${crd_rows}" ]; then + echo '`*` marks the storage version.' + echo +fi +print_table "Name" "${crd_rows}" +echo +echo "### Resources" +echo +if [ -z "${added}" ] && [ -z "${removed}" ]; then + echo "No changes." +fi +if [ -n "${added}" ]; then + echo "Added:" + printf '%s\n' "${added}" | sed 's/^/- /' +fi +if [ -n "${added}" ] && [ -n "${removed}" ]; then + echo +fi +if [ -n "${removed}" ]; then + echo "Removed:" + printf '%s\n' "${removed}" | sed 's/^/- /' +fi diff --git a/scripts/kubernetes-hydrate/tests/fixtures/identical/expected.md b/scripts/kubernetes-hydrate/tests/fixtures/identical/expected.md new file mode 100644 index 0000000..7326256 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/identical/expected.md @@ -0,0 +1,11 @@ +### Images + +No changes. + +### CustomResourceDefinitions + +No changes. + +### Resources + +No changes. diff --git a/scripts/kubernetes-hydrate/tests/fixtures/identical/manifest.yaml b/scripts/kubernetes-hydrate/tests/fixtures/identical/manifest.yaml new file mode 100644 index 0000000..c05d945 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/identical/manifest.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: same diff --git a/scripts/kubernetes-hydrate/tests/fixtures/invalid/manifest.yaml b/scripts/kubernetes-hydrate/tests/fixtures/invalid/manifest.yaml new file mode 100644 index 0000000..c2b6f36 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/invalid/manifest.yaml @@ -0,0 +1,2 @@ +kind: ConfigMap +metadata: [ diff --git a/scripts/kubernetes-hydrate/tests/fixtures/summarize/base.yaml b/scripts/kubernetes-hydrate/tests/fixtures/summarize/base.yaml new file mode 100644 index 0000000..69182fe --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/summarize/base.yaml @@ -0,0 +1,62 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.example.com +spec: + versions: + - name: v1alpha1 + served: true + storage: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: gadgets.example.com +spec: + versions: + - name: v1 + served: true + storage: true +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app + namespace: ns-a +spec: + template: + spec: + initContainers: + - name: init + image: busybox:1.36 + containers: + - name: app + image: registry.example.com/app:1.0 + args: + - --config-reloader=quay.io/example/reloader:v0.93.1 + - --log-level=info + env: + - name: HELPER_IMAGE + value: ghcr.io/example/helper:2.0 + - name: ENDPOINT + value: http://example.com/path:8080 +--- +apiVersion: monitoring.coreos.com/v1 +kind: Prometheus +metadata: + name: main + namespace: ns-a +spec: + image: quay.io/prometheus/prometheus:v3.14.0 + version: v3.14.0 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: old + namespace: ns-a +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: reader diff --git a/scripts/kubernetes-hydrate/tests/fixtures/summarize/expected.md b/scripts/kubernetes-hydrate/tests/fixtures/summarize/expected.md new file mode 100644 index 0000000..d46e01c --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/summarize/expected.md @@ -0,0 +1,29 @@ +### Images + +Collected from `image` fields and from other string values shaped like `registry/repository:tag` (args, env). + +| Repository | Base | Head | +|---|---|---| +| localhost:5000/sidecar | - | 0.1@sha256:abc | +| nginx | - | (untagged) | +| quay.io/example/reloader | v0.93.1 | v0.94.1 | +| quay.io/prometheus/prometheus | v3.14.0 | v3.15.0 | +| registry.example.com/app | 1.0 | 2.0 | + +### CustomResourceDefinitions + +`*` marks the storage version. + +| Name | Base | Head | +|---|---|---| +| gadgets.example.com | v1* | - | +| widgets.example.com | v1alpha1* | v1alpha1 (not served), v1* | + +### Resources + +Added: +- ConfigMap ns-a/new + +Removed: +- ConfigMap ns-a/old +- CustomResourceDefinition gadgets.example.com diff --git a/scripts/kubernetes-hydrate/tests/fixtures/summarize/head.yaml b/scripts/kubernetes-hydrate/tests/fixtures/summarize/head.yaml new file mode 100644 index 0000000..07a34d5 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/summarize/head.yaml @@ -0,0 +1,63 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.example.com +spec: + versions: + - name: v1alpha1 + served: false + storage: false + - name: v1 + served: true + storage: true +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app + namespace: ns-a +spec: + template: + spec: + initContainers: + - name: init + image: busybox:1.36 + containers: + - name: app + image: registry.example.com/app:2.0 + args: + - --config-reloader=quay.io/example/reloader:v0.94.1 + - --log-level=info + env: + - name: HELPER_IMAGE + value: ghcr.io/example/helper:2.0 + - name: ENDPOINT + value: http://example.com/path:8080 + - name: sidecar + image: localhost:5000/sidecar:0.1@sha256:abc + - name: untagged + image: nginx +--- +--- +apiVersion: monitoring.coreos.com/v1 +kind: Prometheus +metadata: + name: main + namespace: ns-a +spec: + image: quay.io/prometheus/prometheus:v3.15.0 + version: v3.15.0 + thanos: + image: + repository: quay.io/thanos/thanos +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: new + namespace: ns-a +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: reader diff --git a/scripts/kubernetes-hydrate/tests/render-diff.test.sh b/scripts/kubernetes-hydrate/tests/render-diff.test.sh new file mode 100755 index 0000000..04a9650 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/render-diff.test.sh @@ -0,0 +1,88 @@ +#!/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_absent() { if grep -qF -- "$2" "$3"; then fail "$1"; sed 's/^/# /' "$3"; else pass "$1"; fi; } + +write_deployment() { + local dir="$1" name="$2" image="$3" + mkdir -p "${dir}/kustomization" + printf 'resources:\n - deployment.yaml\n' > "${dir}/kustomization/kustomization.yaml" + cat > "${dir}/kustomization/deployment.yaml" < "${repo}/aws/eks/production/env.hcl" +write_deployment "${repo}/kubernetes/components/demo/production" demo nginx:1.0 +write_deployment "${repo}/kubernetes/components/gone/production" gone busybox:1.36 +write_deployment "${repo}/kubernetes/components/untouched/production" untouched redis:7 +touch "${repo}/kubernetes/components/demo/namespace.yaml" +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 + +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/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" +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 head + +out="${work}/out" +(cd "${repo}" && bash .github/scripts/kubernetes-hydrate/render-diff.sh HEAD^ "${out}") > "${work}/stdout" 2>&1 || { fail "render-diff exits 0"; sed 's/^/# /' "${work}/stdout"; } +summary="${out}/summary.md" + +expect_contains "lists a changed component" "## demo (production)" "${summary}" +expect_contains "reports the changed image" "| nginx | 1.0 | 1.1 |" "${summary}" +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 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 +expect_absent "skips components without changes" "untouched" "${summary}" +if [ -z "$(git -C "${repo}" status --porcelain)" ] && [ "$(git -C "${repo}" worktree list | wc -l)" -eq 1 ]; then + pass "leaves the checkout and worktree list clean" +else + fail "leaves the checkout and worktree list clean" + git -C "${repo}" status --porcelain | sed 's/^/# /' +fi + +failing_bin="${work}/failing-bin" +mkdir -p "${failing_bin}" +printf '#!/bin/sh\nexit 1\n' > "${failing_bin}/yq" +chmod +x "${failing_bin}/yq" +(cd "${repo}" && PATH="${failing_bin}:${PATH}" bash .github/scripts/kubernetes-hydrate/render-diff.sh HEAD^ "${work}/out-failing") > /dev/null 2>&1 || fail "render-diff exits 0 when summarizing fails" +expect_contains "reports a summarizing failure instead of aborting" "Summarizing failed" "${work}/out-failing/summary.md" + +echo "# changed" >> "${repo}/README.md" +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 docs +(cd "${repo}" && bash .github/scripts/kubernetes-hydrate/render-diff.sh HEAD^ "${work}/out-none") > /dev/null 2>&1 || fail "render-diff exits 0 without components" +expect_contains "reports when no component changed" "No Kubernetes components changed." "${work}/out-none/summary.md" + +exit "${failures}" diff --git a/scripts/kubernetes-hydrate/tests/summarize-manifest-diff.test.sh b/scripts/kubernetes-hydrate/tests/summarize-manifest-diff.test.sh new file mode 100755 index 0000000..1d7d7c0 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/summarize-manifest-diff.test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +summarize="${here}/../summarize-manifest-diff.sh" +fixtures="${here}/fixtures" +failures=0 + +check() { + local name="$1" base="$2" head="$3" expected="$4" actual + if actual="$(bash "${summarize}" "${base}" "${head}" 2>&1)" && diff -u "${expected}" <(printf '%s\n' "${actual}"); then + echo "ok - ${name}" + else + echo "not ok - ${name}" + printf '%s\n' "${actual}" | sed 's/^/# /' + failures=$((failures + 1)) + fi +} + +check "reports image, CRD and resource changes" \ + "${fixtures}/summarize/base.yaml" "${fixtures}/summarize/head.yaml" "${fixtures}/summarize/expected.md" +check "reports no changes for identical manifests" \ + "${fixtures}/identical/manifest.yaml" "${fixtures}/identical/manifest.yaml" "${fixtures}/identical/expected.md" + +for side in base head; do + if [ "${side}" = base ]; then args=("${fixtures}/invalid/manifest.yaml" "${fixtures}/identical/manifest.yaml"); else args=("${fixtures}/identical/manifest.yaml" "${fixtures}/invalid/manifest.yaml"); fi + if bash "${summarize}" "${args[@]}" > /dev/null 2>&1; then + echo "not ok - fails on an unparsable ${side} manifest" + failures=$((failures + 1)) + else + echo "ok - fails on an unparsable ${side} manifest" + fi +done + +exit "${failures}" diff --git a/scripts/release-please/find-configs.sh b/scripts/release-please/find-configs.sh new file mode 100755 index 0000000..6006f26 --- /dev/null +++ b/scripts/release-please/find-configs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +git ls-files ':(glob)**/release-please-config.json' | + jq -R -s -c 'split("\n") | map(select(. != "") | sub("/?release-please-config\\.json$"; "") | if . == "" then "." else . end) | sort' diff --git a/scripts/release-please/find-configs.test.sh b/scripts/release-please/find-configs.test.sh new file mode 100755 index 0000000..b1396dd --- /dev/null +++ b/scripts/release-please/find-configs.test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +find_configs="${script_dir}/find-configs.sh" +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT +failed=0 + +check() { + local name="$1" expected="$2" repo actual path + shift 2 + repo="$(mktemp -d "${work}/repo.XXXXXX")" + git -C "$repo" init -q + for path in "$@"; do + mkdir -p "${repo}/$(dirname "$path")" + echo '{}' >"${repo}/${path}" + done + git -C "$repo" add -A + actual="$(cd "$repo" && bash "$find_configs" 2>&1)" + if [ "$actual" = "$expected" ]; then + echo "PASS ${name}" + else + echo "FAIL ${name}" + echo " expected: ${expected}" + echo " actual: ${actual}" + failed=1 + fi +} + +test_finds_nested_configs() { + check test_finds_nested_configs '["a","b/c"]' a/release-please-config.json b/c/release-please-config.json b/c/other.json +} + +test_finds_root_config() { + check test_finds_root_config '["."]' release-please-config.json +} + +test_finds_none() { + check test_finds_none '[]' a/other.json +} + +test_finds_nested_configs +test_finds_root_config +test_finds_none + +exit "$failed" From 7cbb5a4b75acbed815c22fa362b9ed47476fe3bc Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 03:42:44 +0900 Subject: [PATCH 03/12] fix: resolve render-diff sibling scripts from its own location render-diff.sh looked for hydrate-component.sh and summarize-manifest-diff.sh under the caller's .github/scripts/kubernetes-hydrate. Shared workflows run it from .shared-workflows/scripts/kubernetes-hydrate, where the caller's checkout has no such directory, so every component reported a render failure while the script exited 0. It now takes them from its own directory and reads the caller's files from the working directory. The test no longer copies the scripts into the fixture repository, so it fails when the sibling lookup depends on the caller's checkout. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- scripts/kubernetes-hydrate/render-diff.sh | 2 +- scripts/kubernetes-hydrate/tests/render-diff.test.sh | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/scripts/kubernetes-hydrate/render-diff.sh b/scripts/kubernetes-hydrate/render-diff.sh index 1446774..9eec558 100755 --- a/scripts/kubernetes-hydrate/render-diff.sh +++ b/scripts/kubernetes-hydrate/render-diff.sh @@ -4,8 +4,8 @@ set -euo pipefail base_ref="${1:?base revision required}" out_dir="${2:?output directory required}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$(git rev-parse --show-toplevel)" -script_dir="$(pwd)/.github/scripts/kubernetes-hydrate" mkdir -p "${out_dir}" out_dir="$(cd "${out_dir}" && pwd)" summary="${out_dir}/summary.md" diff --git a/scripts/kubernetes-hydrate/tests/render-diff.test.sh b/scripts/kubernetes-hydrate/tests/render-diff.test.sh index 04a9650..cc897ad 100755 --- a/scripts/kubernetes-hydrate/tests/render-diff.test.sh +++ b/scripts/kubernetes-hydrate/tests/render-diff.test.sh @@ -31,8 +31,7 @@ YAML } repo="${work}/repo" -mkdir -p "${repo}/.github/scripts/kubernetes-hydrate" "${repo}/aws/eks/production" -cp "${scripts}"/hydrate-component.sh "${scripts}"/render-diff.sh "${scripts}"/summarize-manifest-diff.sh "${repo}/.github/scripts/kubernetes-hydrate/" +mkdir -p "${repo}/aws/eks/production" printf 'locals {\n cluster_version = "1.33"\n}\n' > "${repo}/aws/eks/production/env.hcl" write_deployment "${repo}/kubernetes/components/demo/production" demo nginx:1.0 write_deployment "${repo}/kubernetes/components/gone/production" gone busybox:1.36 @@ -52,7 +51,7 @@ 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 head out="${work}/out" -(cd "${repo}" && bash .github/scripts/kubernetes-hydrate/render-diff.sh HEAD^ "${out}") > "${work}/stdout" 2>&1 || { fail "render-diff exits 0"; sed 's/^/# /' "${work}/stdout"; } +(cd "${repo}" && bash "${scripts}/render-diff.sh" HEAD^ "${out}") > "${work}/stdout" 2>&1 || { fail "render-diff exits 0"; sed 's/^/# /' "${work}/stdout"; } summary="${out}/summary.md" expect_contains "lists a changed component" "## demo (production)" "${summary}" @@ -76,13 +75,13 @@ failing_bin="${work}/failing-bin" mkdir -p "${failing_bin}" printf '#!/bin/sh\nexit 1\n' > "${failing_bin}/yq" chmod +x "${failing_bin}/yq" -(cd "${repo}" && PATH="${failing_bin}:${PATH}" bash .github/scripts/kubernetes-hydrate/render-diff.sh HEAD^ "${work}/out-failing") > /dev/null 2>&1 || fail "render-diff exits 0 when summarizing fails" +(cd "${repo}" && PATH="${failing_bin}:${PATH}" bash "${scripts}/render-diff.sh" HEAD^ "${work}/out-failing") > /dev/null 2>&1 || fail "render-diff exits 0 when summarizing fails" expect_contains "reports a summarizing failure instead of aborting" "Summarizing failed" "${work}/out-failing/summary.md" echo "# changed" >> "${repo}/README.md" 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 docs -(cd "${repo}" && bash .github/scripts/kubernetes-hydrate/render-diff.sh HEAD^ "${work}/out-none") > /dev/null 2>&1 || fail "render-diff exits 0 without components" +(cd "${repo}" && bash "${scripts}/render-diff.sh" HEAD^ "${work}/out-none") > /dev/null 2>&1 || fail "render-diff exits 0 without components" expect_contains "reports when no component changed" "No Kubernetes components changed." "${work}/out-none/summary.md" exit "${failures}" From cba64dff3d78de794695e72351fc79cb4a534556 Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 03:42:44 +0900 Subject: [PATCH 04/12] fix: fail docs-sync-check when SCOPE_PATTERN is not a valid regex grep exits 2 for an invalid pattern, and the negated condition treated that as no match. decide.sh then answered skip-unchanged, so a broken pattern silently skipped the docs check. It now exits 1 with an error that names SCOPE_PATTERN, and passes the pattern with -e so a leading dash stays part of it. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- scripts/docs-sync-check/decide.sh | 9 ++++++++- scripts/docs-sync-check/decide.test.sh | 10 ++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/docs-sync-check/decide.sh b/scripts/docs-sync-check/decide.sh index c6f6fac..14245b0 100755 --- a/scripts/docs-sync-check/decide.sh +++ b/scripts/docs-sync-check/decide.sh @@ -57,7 +57,14 @@ if [ "$is_approved" = "true" ]; then exit 0 fi -if ! grep -qE "$scope_pattern" <<<"$changed_files"; then +scope_status=0 +grep -qE -e "$scope_pattern" <<<"$changed_files" || scope_status=$? +if [ "$scope_status" -gt 1 ]; then + echo "SCOPE_PATTERN is not a valid extended regular expression: ${scope_pattern}" >&2 + exit 1 +fi + +if [ "$scope_status" -eq 1 ]; then emit skip-unchanged exit 0 fi diff --git a/scripts/docs-sync-check/decide.test.sh b/scripts/docs-sync-check/decide.test.sh index c86e4c1..26358c6 100755 --- a/scripts/docs-sync-check/decide.test.sh +++ b/scripts/docs-sync-check/decide.test.sh @@ -45,9 +45,10 @@ check push_after_approval_without_dystopia synchronize '' true User false '' $'p check skips_changes_outside_the_scope_pattern opened '' false User false '' $'dystopia/a.rb' skip-unchanged false '^platform/' check judges_changes_inside_the_scope_pattern opened '' false User false '' $'platform/a.tf' judge false '^platform/' -check_requires_scope_pattern() { - local name="$1" actual - if actual="$(printf '%s' $'dystopia/a.rb' | env -u SCOPE_PATTERN EVENT_ACTION=opened EVENT_LABEL='' APPROVED=false AUTHOR_TYPE=Bot BODY_OR_BASE_CHANGED=false PRIOR_STATE='' bash "$decide" 2>&1)"; then +check_rejects_scope_pattern() { + local name="$1" author="$2" actual + shift 2 + if actual="$(printf '%s' $'dystopia/a.rb' | env "$@" EVENT_ACTION=opened EVENT_LABEL='' APPROVED=false AUTHOR_TYPE="$author" BODY_OR_BASE_CHANGED=false PRIOR_STATE='' bash "$decide" 2>&1)"; then echo "FAIL ${name}" echo " expected a non-zero exit, actual output: ${actual//$'\n'/ | }" failed=1 @@ -60,6 +61,7 @@ check_requires_scope_pattern() { fi } -check_requires_scope_pattern rejects_missing_scope_pattern_even_when_the_event_exits_early +check_rejects_scope_pattern rejects_missing_scope_pattern_even_when_the_event_exits_early Bot -u SCOPE_PATTERN +check_rejects_scope_pattern rejects_invalid_scope_pattern User SCOPE_PATTERN='(' exit "$failed" From 1e6a3be7e6d02a43c3bdc9c6719e706078f7e018 Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 03:44:53 +0900 Subject: [PATCH 05/12] feat: add the shared pull request checks and gatekeeper Add reusable workflows for the Actions SHA-pin lint, the semantic pull request title check, the CI gatekeeper and the deploy label dispatcher. Add this repository's own callers for the first three, so the shared workflows are exercised from the same commit that defines them. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .github/workflows/ci-gatekeeper.yaml | 14 ++++++ .github/workflows/lint-actions.yml | 13 +++++ .../workflows/reusable--ci-gatekeeper.yaml | 23 +++++++++ ...usable--label-base-deploy--dispatcher.yaml | 49 +++++++++++++++++++ .github/workflows/reusable--lint-actions.yaml | 13 +++++ .../reusable--semantic-pull-request.yaml | 30 ++++++++++++ .github/workflows/semantic-pull-request.yml | 16 ++++++ 7 files changed, 158 insertions(+) create mode 100644 .github/workflows/ci-gatekeeper.yaml create mode 100644 .github/workflows/lint-actions.yml create mode 100644 .github/workflows/reusable--ci-gatekeeper.yaml create mode 100644 .github/workflows/reusable--label-base-deploy--dispatcher.yaml create mode 100644 .github/workflows/reusable--lint-actions.yaml create mode 100644 .github/workflows/reusable--semantic-pull-request.yaml create mode 100644 .github/workflows/semantic-pull-request.yml diff --git a/.github/workflows/ci-gatekeeper.yaml b/.github/workflows/ci-gatekeeper.yaml new file mode 100644 index 0000000..da7d066 --- /dev/null +++ b/.github/workflows/ci-gatekeeper.yaml @@ -0,0 +1,14 @@ +# Waits for every other workflow on a pull request so that one required check gates the merge. +name: 'CI Gatekeeper' + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - '**' + +jobs: + gatekeeper: + permissions: + actions: read + uses: ./.github/workflows/reusable--ci-gatekeeper.yaml diff --git a/.github/workflows/lint-actions.yml b/.github/workflows/lint-actions.yml new file mode 100644 index 0000000..e41c2ca --- /dev/null +++ b/.github/workflows/lint-actions.yml @@ -0,0 +1,13 @@ +# Checks that every action is pinned to a full commit SHA. +name: Lint GitHub Actions + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + permissions: + contents: read + uses: ./.github/workflows/reusable--lint-actions.yaml diff --git a/.github/workflows/reusable--ci-gatekeeper.yaml b/.github/workflows/reusable--ci-gatekeeper.yaml new file mode 100644 index 0000000..9d297da --- /dev/null +++ b/.github/workflows/reusable--ci-gatekeeper.yaml @@ -0,0 +1,23 @@ +# Waits for every other workflow on a pull request so that one required check gates the merge. +name: 'Reusable - CI Gatekeeper' + +on: + workflow_call: + inputs: + exclude-workflow-names: + required: false + type: string + default: '' + description: 'Workflow names the gatekeeper does not wait for, one per line' + +jobs: + ci-gatekeeper: + name: CI Gatekeeper + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Wait for other workflows + uses: int128/wait-for-workflows-action@b320164b37276db89a12829914d6eb6ef4973d2b # v1.108.0 + with: + max-timeout-seconds: 1800 + exclude-workflow-names: ${{ inputs.exclude-workflow-names }} diff --git a/.github/workflows/reusable--label-base-deploy--dispatcher.yaml b/.github/workflows/reusable--label-base-deploy--dispatcher.yaml new file mode 100644 index 0000000..737db25 --- /dev/null +++ b/.github/workflows/reusable--label-base-deploy--dispatcher.yaml @@ -0,0 +1,49 @@ +# Adds deploy:{service} labels to a pull request from the stack paths it changes. +name: 'Reusable - Label Base Deploy - Dispatcher' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + config-path: + required: false + type: string + default: 'workflow-config.yaml' + description: 'Path to the workflow config that maps stack paths to services' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + dispatch-labels: + name: 'Dispatch Labels' + if: ${{ !startsWith(github.head_ref, 'release-please--') }} + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Get PR information + id: pr-info + uses: jwalton/gh-find-current-pr@f3d61b485d2801773f7a07b2aaa3306bd8f8e653 # v1.3.5 + with: + github-token: ${{ steps.app-token.outputs.token }} + state: all + continue-on-error: true + + - name: Dispatch Labels + uses: panicboat/deploy-actions/label-dispatcher@1ab520e5ef33b8d744982762ffc4e08009a34744 # v2.0.1 + with: + pr-number: ${{ steps.pr-info.outputs.number }} + repository: ${{ github.repository }} + github-token: ${{ steps.app-token.outputs.token }} + config-path: ${{ inputs.config-path }} diff --git a/.github/workflows/reusable--lint-actions.yaml b/.github/workflows/reusable--lint-actions.yaml new file mode 100644 index 0000000..87f0361 --- /dev/null +++ b/.github/workflows/reusable--lint-actions.yaml @@ -0,0 +1,13 @@ +# Checks that every action is pinned to a full commit SHA. +name: 'Reusable - Lint GitHub Actions' + +on: + workflow_call: + +jobs: + ensure-sha-pinned: + name: Ensure actions are pinned to SHAs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: zgosalvez/github-actions-ensure-sha-pinned-actions@c4e71056006d29f90204b2d063be337109507d62 # v5.0.10 diff --git a/.github/workflows/reusable--semantic-pull-request.yaml b/.github/workflows/reusable--semantic-pull-request.yaml new file mode 100644 index 0000000..a81b462 --- /dev/null +++ b/.github/workflows/reusable--semantic-pull-request.yaml @@ -0,0 +1,30 @@ +# Validates that the pull request title follows Conventional Commits. +name: 'Reusable - Semantic Pull Request' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + validate: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml new file mode 100644 index 0000000..8d1ba09 --- /dev/null +++ b/.github/workflows/semantic-pull-request.yml @@ -0,0 +1,16 @@ +# Validates that the pull request title follows Conventional Commits. +name: Semantic Pull Request + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +jobs: + semantic: + permissions: + contents: read + uses: ./.github/workflows/reusable--semantic-pull-request.yaml + with: + app-id: ${{ vars.APP_ID }} + secrets: + private-key: ${{ secrets.APP_PRIVATE_KEY }} From e984aa716d88f67aef9dde7c6780dc9e72c621a3 Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 03:50:49 +0900 Subject: [PATCH 06/12] feat: add the shared deploy stacks and resolver monorepo and platform each carried their own label-base-deploy resolver and deploy stacks. One resolver now serves both, and each repository enables stacks only through its workflow-config.yaml. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .../reusable--container-builder.yaml | 93 ++++++++ .../reusable--kubernetes-builder.yaml | 102 +++++++++ .../reusable--kubernetes-hydrator.yaml | 130 +++++++++++ ...reusable--label-base-deploy--resolver.yaml | 205 ++++++++++++++++++ .../reusable--terragrunt-runner.yaml | 95 ++++++++ 5 files changed, 625 insertions(+) create mode 100644 .github/workflows/reusable--container-builder.yaml create mode 100644 .github/workflows/reusable--kubernetes-builder.yaml create mode 100644 .github/workflows/reusable--kubernetes-hydrator.yaml create mode 100644 .github/workflows/reusable--label-base-deploy--resolver.yaml create mode 100644 .github/workflows/reusable--terragrunt-runner.yaml diff --git a/.github/workflows/reusable--container-builder.yaml b/.github/workflows/reusable--container-builder.yaml new file mode 100644 index 0000000..3b5be97 --- /dev/null +++ b/.github/workflows/reusable--container-builder.yaml @@ -0,0 +1,93 @@ +# Builds the container image of one service and pushes it to ghcr.io. +name: Reusable Container Builder + +on: + workflow_call: + inputs: + image-name: + required: true + type: string + description: 'Name of the image (e.g. service-name)' + working-directory: + required: true + type: string + description: 'Directory containing the Dockerfile' + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + semver-tag: + required: false + type: string + default: '' + description: 'Optional semver tag (e.g. v0.1.0) to add to the built image. Only set when called from release event flow.' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + build-and-push: + if: inputs.image-name != '' + runs-on: ubuntu-26.04-arm + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for Dockerfile + id: dockerfile + run: | + if [ -f "${{ inputs.working-directory }}/Dockerfile" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the Container registry + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Extract metadata (tags, labels) for Docker + id: meta + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/${{ github.repository }}/${{ inputs.image-name }} + tags: | + type=sha + type=ref,event=pr + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=${{ github.actor }} + type=semver,pattern={{raw}},value=${{ inputs.semver-tag }},enable=${{ inputs.semver-tag != '' }} + + - name: Build and push Docker image + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + platforms: linux/arm64 + context: ${{ inputs.working-directory }} + file: ${{ inputs.working-directory }}/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/reusable--kubernetes-builder.yaml b/.github/workflows/reusable--kubernetes-builder.yaml new file mode 100644 index 0000000..5cc2c4b --- /dev/null +++ b/.github/workflows/reusable--kubernetes-builder.yaml @@ -0,0 +1,102 @@ +# Posts the kustomize diff of one kustomization directory as a pull request comment. +name: 'Reusable - Kubernetes Builder' + +on: + workflow_call: + inputs: + service-name: + required: true + type: string + description: 'Service name for identification' + environment: + required: true + type: string + description: 'Environment name (develop, staging, production, etc.)' + kustomization-directory: + required: true + type: string + description: 'Directory containing the kustomization.yaml to diff (skips the job when empty)' + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + kustomize-diff: + name: 'Kustomize Diff' + if: inputs.kustomization-directory != '' + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Get PR information + id: pr-info + uses: jwalton/gh-find-current-pr@f3d61b485d2801773f7a07b2aaa3306bd8f8e653 # v1.3.5 + with: + github-token: ${{ steps.app-token.outputs.token }} + state: all + continue-on-error: true + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ steps.app-token.outputs.token }} + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Checkout base ref + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + token: ${{ steps.app-token.outputs.token }} + + - name: Build head manifests + id: kustomize-head + uses: int128/kustomize-action@19a766fc2f43be39d1436d59555033be8aca07db # v1.196.0 + with: + kustomization: ${{ inputs.kustomization-directory }}/kustomization.yaml + write-individual-files: true + + - name: Build base manifests + id: kustomize-base + uses: int128/kustomize-action@19a766fc2f43be39d1436d59555033be8aca07db # v1.196.0 + with: + base-directory: base + kustomization: ${{ inputs.kustomization-directory }}/kustomization.yaml + write-individual-files: true + + - name: Diff manifests + id: diff + uses: int128/diff-action@274034d712aef611adcbd41e5a647f43e4edd5a5 # v2.41.0 + with: + base: ${{ steps.kustomize-base.outputs.directory }} + head: ${{ steps.kustomize-head.outputs.directory }} + + - name: Comment PR + if: steps.pr-info.outputs.number != '' + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + message: | + ## Kustomize Diff + + **Service**: `${{ inputs.service-name }}` + **Environment**: `${{ inputs.environment }}` + + ${{ steps.diff.outputs.comment-body || 'No changes' }} + comment-tag: "kubernetes-${{ inputs.service-name }}-${{ inputs.environment }}" + mode: upsert + pr-number: ${{ steps.pr-info.outputs.number }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + reactions: ${{ steps.diff.outputs.has-diff == 'true' && 'rocket' || '' }} + continue-on-error: true diff --git a/.github/workflows/reusable--kubernetes-hydrator.yaml b/.github/workflows/reusable--kubernetes-hydrator.yaml new file mode 100644 index 0000000..537adbf --- /dev/null +++ b/.github/workflows/reusable--kubernetes-hydrator.yaml @@ -0,0 +1,130 @@ +# Renders the given components into kubernetes/manifests and commits the result to the pull request branch. +name: 'Reusable - Kubernetes Hydrator' + +on: + workflow_call: + inputs: + environment: + required: true + type: string + description: 'Target environment for hydration (e.g., local)' + services: + required: true + type: string + description: 'JSON array of service names to hydrate (e.g. ["coredns","cilium"])' + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +concurrency: + group: kubernetes-hydrate-${{ github.event.pull_request.number || github.ref }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + hydrate: + name: 'Hydrate' + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Get PR information + id: pr-info + uses: jwalton/gh-find-current-pr@f3d61b485d2801773f7a07b2aaa3306bd8f8e653 # v1.3.5 + with: + github-token: ${{ steps.app-token.outputs.token }} + state: all + continue-on-error: true + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ steps.app-token.outputs.token }} + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - 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: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Hydrate changed components + env: + SERVICES_JSON: ${{ inputs.services }} + run: | + set -euo pipefail + for svc in $(echo "$SERVICES_JSON" | jq -r '.[]'); do + bash .shared-workflows/scripts/kubernetes-hydrate/hydrate-component.sh "$svc" "${{ inputs.environment }}" + done + + - name: Hydrate index + run: bash .shared-workflows/scripts/kubernetes-hydrate/hydrate-index.sh "${{ inputs.environment }}" + + - name: Commit and push hydrated manifests + id: auto-commit + uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0 + with: + commit_message: 'chore(kubernetes/manifests/${{ inputs.environment }}): hydrate manifests' + commit_options: '--signoff' + file_pattern: 'kubernetes/manifests/${{ inputs.environment }}/' + commit_user_name: 'github-actions[bot]' + commit_user_email: 'github-actions[bot]@users.noreply.github.com' + + - name: Compute index diff + id: index-diff + if: steps.pr-info.outputs.number != '' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + ENV: ${{ inputs.environment }} + run: | + set -euo pipefail + diff=$(git diff "$BASE_SHA" HEAD -- \ + "kubernetes/manifests/$ENV/kustomization.yaml" \ + "kubernetes/manifests/$ENV/00-namespaces/" || true) + { + echo 'diff<> "$GITHUB_OUTPUT" + if [ -n "$diff" ]; then + echo "has-diff=true" >> "$GITHUB_OUTPUT" + else + echo "has-diff=false" >> "$GITHUB_OUTPUT" + fi + + - name: Comment index diff + if: steps.index-diff.outputs.has-diff == 'true' && steps.pr-info.outputs.number != '' + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + message: | + ## Kubernetes Index Diff + + **Environment**: `${{ inputs.environment }}` + + ```diff + ${{ steps.index-diff.outputs.diff }} + ``` + comment-tag: 'kubernetes-index-${{ inputs.environment }}' + mode: upsert + pr-number: ${{ steps.pr-info.outputs.number }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + continue-on-error: true diff --git a/.github/workflows/reusable--label-base-deploy--resolver.yaml b/.github/workflows/reusable--label-base-deploy--resolver.yaml new file mode 100644 index 0000000..eaf1e56 --- /dev/null +++ b/.github/workflows/reusable--label-base-deploy--resolver.yaml @@ -0,0 +1,205 @@ +# Resolves deploy labels into targets and runs each stack the caller's workflow config enables: terragrunt plan/apply, container builds, and kustomize/helmfile diffs on pull requests. +name: 'Reusable - Label Base Deploy - Resolver' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + config-path: + required: false + type: string + default: 'workflow-config.yaml' + description: 'Path to the workflow config that defines the stacks and services' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + resolve: + name: 'Resolve Targets' + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.resolver.outputs.targets }} + has-targets: ${{ steps.resolver.outputs.has-targets }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - 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: Validate apply_trigger + run: bash .shared-workflows/scripts/terragrunt-release/validate-apply-trigger.sh "${{ inputs.config-path }}" + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Get PR information + id: pr-info + uses: jwalton/gh-find-current-pr@f3d61b485d2801773f7a07b2aaa3306bd8f8e653 # v1.3.5 + with: + github-token: ${{ steps.app-token.outputs.token }} + state: all + continue-on-error: true + + - name: Label Resolver + id: resolver + uses: panicboat/deploy-actions/label-resolver@1ab520e5ef33b8d744982762ffc4e08009a34744 # v2.0.1 + with: + repository: ${{ github.repository }} + pr-number: ${{ steps.pr-info.outputs.number }} + github-token: ${{ steps.app-token.outputs.token }} + config-path: ${{ inputs.config-path }} + + deploy-terragrunt: + name: 'Deploy Terragrunt (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: resolve + if: | + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"terragrunt"') + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--terragrunt-runner.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + action-type: ${{ matrix.target.stack == 'terragrunt' && (github.event_name == 'pull_request' && 'plan' || (matrix.target.apply_trigger == 'merge' && 'apply' || '')) || '' }} + iam-role: ${{ github.event_name == 'pull_request' && matrix.target.iam_role_plan || matrix.target.iam_role_apply }} + aws-region: ${{ matrix.target.aws_region }} + working-directory: ${{ matrix.target.working_directory }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + deploy-container: + name: 'Deploy Container (${{ matrix.target.service }})' + needs: resolve + if: | + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"container"') + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + concurrency: + group: image-build-${{ matrix.target.stack_id }}-${{ matrix.target.service }}-${{ github.sha }} + cancel-in-progress: false + uses: $/.github/workflows/reusable--container-builder.yaml + with: + image-name: ${{ matrix.target.stack == 'container' && matrix.target.service || '' }} + working-directory: ${{ matrix.target.working_directory }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + deploy-kustomize: + name: 'Deploy Kustomize (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: resolve + if: | + github.event_name == 'pull_request' && + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"kustomize"') + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--kubernetes-builder.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + kustomization-directory: ${{ matrix.target.stack == 'kustomize' && matrix.target.working_directory || '' }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + helmfile-targets-group: + name: 'Group Helmfile Targets by Environment' + needs: resolve + if: | + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"helmfile"') + runs-on: ubuntu-latest + outputs: + environments: ${{ steps.group.outputs.environments }} + steps: + - name: Group by environment + id: group + env: + TARGETS: ${{ needs.resolve.outputs.targets }} + run: | + set -euo pipefail + grouped=$(echo "$TARGETS" | jq -c ' + [.[] | select(.stack == "helmfile")] + | group_by(.environment) + | map({environment: .[0].environment, services: [.[].service]}) + ') + echo "environments=$grouped" >> "$GITHUB_OUTPUT" + + deploy-helmfile-hydrate: + name: 'Hydrate Helmfile (${{ matrix.env.environment }})' + needs: helmfile-targets-group + strategy: + matrix: + env: ${{ fromJson(needs.helmfile-targets-group.outputs.environments) }} + fail-fast: false + uses: $/.github/workflows/reusable--kubernetes-hydrator.yaml + with: + environment: ${{ matrix.env.environment }} + services: ${{ toJson(matrix.env.services) }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + deploy-helmfile: + name: 'Deploy Helmfile (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: [resolve, deploy-helmfile-hydrate] + if: | + github.event_name == 'pull_request' && + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"helmfile"') + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--kubernetes-builder.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + kustomization-directory: ${{ matrix.target.stack == 'helmfile' && format('kubernetes/manifests/{0}/{1}', matrix.target.environment, matrix.target.service) || '' }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + deployment-summary: + name: 'Deployment Summary' + needs: [resolve, deploy-terragrunt, deploy-container, deploy-kustomize, deploy-helmfile-hydrate, deploy-helmfile] + if: always() && needs.resolve.outputs.has-targets == 'true' + runs-on: ubuntu-latest + steps: + - name: Summary + run: | + echo "📊 Deployment Summary" + echo "Event: ${{ github.event_name }}" + echo "Branch: ${{ github.ref_name }}" + echo "Targets: ${{ needs.resolve.outputs.targets }}" + echo "Terragrunt Job Status: ${{ needs.deploy-terragrunt.result }}" + echo "Container Job Status: ${{ needs.deploy-container.result }}" + echo "Kustomize Job Status: ${{ needs.deploy-kustomize.result }}" + echo "Helmfile Hydrate Job Status: ${{ needs.deploy-helmfile-hydrate.result }}" + echo "Helmfile Job Status: ${{ needs.deploy-helmfile.result }}" diff --git a/.github/workflows/reusable--terragrunt-runner.yaml b/.github/workflows/reusable--terragrunt-runner.yaml new file mode 100644 index 0000000..35342ff --- /dev/null +++ b/.github/workflows/reusable--terragrunt-runner.yaml @@ -0,0 +1,95 @@ +# Runs terragrunt plan or apply in one working directory and reports the result with tfcmt. +name: 'Reusable - Terragrunt Runner' + +on: + workflow_call: + inputs: + service-name: + required: true + type: string + description: 'Service name for terragrunt execution' + environment: + required: true + type: string + description: 'Environment name (develop, staging, production, etc.)' + action-type: + required: true + type: string + description: 'Action type: plan or apply' + iam-role: + required: true + type: string + description: 'IAM role for plan/apply operations' + aws-region: + required: true + type: string + description: 'AWS region' + working-directory: + required: true + type: string + description: 'Working directory for terragrunt execution' + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + ref: + required: false + type: string + default: '' + description: 'Git ref to check out (defaults to the triggering ref)' + github-environment: + required: false + type: string + default: '' + description: 'GitHub environment the job runs in (none when empty)' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + run-terragrunt: + name: 'Run Terragrunt' + if: inputs.action-type != '' + runs-on: ubuntu-latest + environment: ${{ inputs.github-environment }} + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: ${{ inputs.iam-role }} + aws-region: ${{ inputs.aws-region }} + role-session-name: GitHubActions-Terragrunt-${{ inputs.action-type }}-${{ inputs.environment }} + audience: sts.amazonaws.com + + - name: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Run Terragrunt + working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + TF_INPUT: 'false' + # tfcmt cannot parse the output while terragrunt prefixes each line with its log metadata. + TG_LOG_DISABLE: 'true' + TFCMT_PLAN_PATCH: 'true' + TFCMT_DISABLE_LABEL: 'true' + SERVICE_NAME: ${{ inputs.service-name }} + ENVIRONMENT: ${{ inputs.environment }} + ACTION_TYPE: ${{ inputs.action-type }} + run: tfcmt -var "target:$SERVICE_NAME/$ENVIRONMENT" "$ACTION_TYPE" -- terragrunt "$ACTION_TYPE" -no-color ${{ inputs.action-type == 'apply' && '-auto-approve' || '' }} From 1a94252f87ed1440480db0d13bf59fb7074700e6 Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 03:57:17 +0900 Subject: [PATCH 07/12] feat: add the shared release workflows Callers need one place for the release flow, so the release-please, plan, deploy and build workflows and the terragrunt release config check move here as reusable workflows. They read the scripts from a checkout of this repository at the workflow's own sha and call the shared terragrunt runner and container builder without a ref. Callers grant the permissions and own the workflow-level concurrency, so the reusables only declare the per-job permissions the originals had. The tag comes from github.event.inputs because the inputs context of a reusable workflow holds its own workflow_call inputs. This repository releases itself with release-please at the root, and workflows/test.sh pins the structure the release and deploy workflows depend on. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .github/workflows/release--please.yaml | 21 ++++++ .../workflows/reusable--release--build.yaml | 69 ++++++++++++++++++ .../workflows/reusable--release--deploy.yaml | 73 +++++++++++++++++++ .../workflows/reusable--release--plan.yaml | 65 +++++++++++++++++ .../workflows/reusable--release--please.yaml | 60 +++++++++++++++ .../reusable--test-terragrunt-release.yaml | 29 ++++++++ release-please-config.json | 25 +++++++ release-please-manifest.json | 3 + scripts/workflows/test.sh | 50 +++++++++++++ 9 files changed, 395 insertions(+) create mode 100644 .github/workflows/release--please.yaml create mode 100644 .github/workflows/reusable--release--build.yaml create mode 100644 .github/workflows/reusable--release--deploy.yaml create mode 100644 .github/workflows/reusable--release--plan.yaml create mode 100644 .github/workflows/reusable--release--please.yaml create mode 100644 .github/workflows/reusable--test-terragrunt-release.yaml create mode 100644 release-please-config.json create mode 100644 release-please-manifest.json create mode 100755 scripts/workflows/test.sh diff --git a/.github/workflows/release--please.yaml b/.github/workflows/release--please.yaml new file mode 100644 index 0000000..e2f4fbb --- /dev/null +++ b/.github/workflows/release--please.yaml @@ -0,0 +1,21 @@ +# Runs release-please for this repository to open release pull requests and publish releases. +name: 'Release - Please' + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: release-please + cancel-in-progress: false + +jobs: + release-please: + permissions: + contents: read + uses: ./.github/workflows/reusable--release--please.yaml + with: + app-id: ${{ vars.APP_ID }} + secrets: + private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/reusable--release--build.yaml b/.github/workflows/reusable--release--build.yaml new file mode 100644 index 0000000..e1aa54c --- /dev/null +++ b/.github/workflows/reusable--release--build.yaml @@ -0,0 +1,69 @@ +# Builds the container image of a published service release or a dispatched tag and tags it with the release version. +name: 'Reusable - Release - Build' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + detect-component: + runs-on: ubuntu-latest + outputs: + service: ${{ steps.parse.outputs.service }} + version: ${{ steps.parse.outputs.version }} + working-directory: ${{ steps.resolve-dir.outputs.working-directory }} + steps: + - name: Parse tag + id: parse + env: + 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]}" + echo "service=$SERVICE" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Resolve working directory + id: resolve-dir + env: + SERVICE: ${{ steps.parse.outputs.service }} + run: | + set -euo pipefail + # Use workflow-config.yaml as the source of truth for deployable stacks. + while IFS= read -r path_pattern; do + dir="${path_pattern//\{service\}/$SERVICE}" + if [ -d "$dir" ]; then + echo "working-directory=$dir" >> "$GITHUB_OUTPUT" + exit 0 + fi + done < <(yq -r '.stacks[] | select(.name == "container") | .paths[]' workflow-config.yaml) + echo "::error::No directory found for service '$SERVICE' under any container stack path in workflow-config.yaml" + exit 1 + + container-build: + needs: detect-component + concurrency: + group: image-build-${{ needs.detect-component.outputs.service }}-${{ github.sha }} + cancel-in-progress: false + uses: $/.github/workflows/reusable--container-builder.yaml + with: + image-name: ${{ needs.detect-component.outputs.service }} + working-directory: ${{ needs.detect-component.outputs.working-directory }} + app-id: ${{ inputs.app-id }} + semver-tag: ${{ needs.detect-component.outputs.version }} + secrets: + private-key: ${{ secrets.private-key }} diff --git a/.github/workflows/reusable--release--deploy.yaml b/.github/workflows/reusable--release--deploy.yaml new file mode 100644 index 0000000..1163351 --- /dev/null +++ b/.github/workflows/reusable--release--deploy.yaml @@ -0,0 +1,73 @@ +# Applies a service's apply_trigger: release environments when its release is published or its tag is dispatched. +name: 'Reusable - Release - Deploy' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + resolve: + name: 'Resolve Release Targets' + runs-on: ubuntu-latest + permissions: + contents: read + env: + TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} + outputs: + targets: ${{ steps.resolve.outputs.targets }} + tag: ${{ steps.resolve.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TAG }} + + - 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: Resolve release targets + id: resolve + run: | + set -euo pipefail + service=$(bash .shared-workflows/scripts/terragrunt-release/release-service.sh --tag "$TAG") + targets=$(bash .shared-workflows/scripts/terragrunt-release/release-targets.sh "$service") + echo "targets=$targets" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + apply: + name: 'Apply Release (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: resolve + if: needs.resolve.outputs.targets != '[]' + concurrency: + group: terragrunt-apply-${{ matrix.target.working_directory }} + cancel-in-progress: false + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--terragrunt-runner.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + action-type: apply + iam-role: ${{ matrix.target.iam_role_apply }} + aws-region: ${{ matrix.target.aws_region }} + working-directory: ${{ matrix.target.working_directory }} + app-id: ${{ inputs.app-id }} + ref: ${{ needs.resolve.outputs.tag }} + github-environment: ${{ matrix.target.environment }} + secrets: + private-key: ${{ secrets.private-key }} diff --git a/.github/workflows/reusable--release--plan.yaml b/.github/workflows/reusable--release--plan.yaml new file mode 100644 index 0000000..dc2cdf8 --- /dev/null +++ b/.github/workflows/reusable--release--plan.yaml @@ -0,0 +1,65 @@ +# Plans a service's apply_trigger: release environments on its release-please pull request. +name: 'Reusable - Release - Plan' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + resolve: + name: 'Resolve Release Targets' + if: startsWith(github.head_ref, 'release-please--branches--main--components--') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + targets: ${{ steps.resolve.outputs.targets }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - 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: Resolve release targets + id: resolve + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + service=$(bash .shared-workflows/scripts/terragrunt-release/release-service.sh --branch "$HEAD_REF") + targets=$(bash .shared-workflows/scripts/terragrunt-release/release-targets.sh "$service") + echo "targets=$targets" >> "$GITHUB_OUTPUT" + + plan: + name: 'Plan Release (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: resolve + if: needs.resolve.outputs.targets != '[]' + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--terragrunt-runner.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + action-type: plan + iam-role: ${{ matrix.target.iam_role_plan }} + aws-region: ${{ matrix.target.aws_region }} + working-directory: ${{ matrix.target.working_directory }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} diff --git a/.github/workflows/reusable--release--please.yaml b/.github/workflows/reusable--release--please.yaml new file mode 100644 index 0000000..fd51f83 --- /dev/null +++ b/.github/workflows/reusable--release--please.yaml @@ -0,0 +1,60 @@ +# Runs release-please for each release-please-config.json to open release pull requests and publish releases. +name: 'Reusable - Release - Please' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + paths: ${{ steps.find.outputs.paths }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - 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: Find release-please configs + id: find + run: | + set -euo pipefail + paths=$(bash .shared-workflows/scripts/release-please/find-configs.sh) + echo "paths=$paths" >> "$GITHUB_OUTPUT" + + release-please: + needs: discover + if: needs.discover.outputs.paths != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + path: ${{ fromJson(needs.discover.outputs.paths) }} + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: ${{ matrix.path == '.' && 'release-please-config.json' || format('{0}/release-please-config.json', matrix.path) }} + manifest-file: ${{ matrix.path == '.' && 'release-please-manifest.json' || format('{0}/release-please-manifest.json', matrix.path) }} diff --git a/.github/workflows/reusable--test-terragrunt-release.yaml b/.github/workflows/reusable--test-terragrunt-release.yaml new file mode 100644 index 0000000..b7c14ba --- /dev/null +++ b/.github/workflows/reusable--test-terragrunt-release.yaml @@ -0,0 +1,29 @@ +# Validates the caller's terragrunt apply_trigger values and the release-please configs of its release environments. +name: 'Reusable - Test Terragrunt Release' + +on: + workflow_call: + +jobs: + validate: + name: 'Validate terragrunt release config' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - 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: Validate apply_trigger + run: bash .shared-workflows/scripts/terragrunt-release/validate-apply-trigger.sh + + - name: Check release-please configs + run: bash .shared-workflows/scripts/terragrunt-release/check-release-configs.sh diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..cde57b8 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "separate-pull-requests": true, + "bootstrap-sha": "e984aa716d88f67aef9dde7c6780dc9e72c621a3", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "refactor", "section": "Code Refactoring" }, + { "type": "chore", "section": "Miscellaneous Chores" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ], + "packages": { + ".": { + "release-type": "simple", + "include-component-in-tag": false, + "release-as": "1.0.0" + } + } +} diff --git a/release-please-manifest.json b/release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/scripts/workflows/test.sh b/scripts/workflows/test.sh new file mode 100755 index 0000000..bdd0255 --- /dev/null +++ b/scripts/workflows/test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(git -C "${script_dir}" rev-parse --show-toplevel)" +workflows_dir="${repo_root}/.github/workflows" + +assert_eq() { + local expected="$1" actual="$2" + if [ "${expected}" != "${actual}" ]; then + echo " expected: ${expected}" >&2 + echo " actual: ${actual}" >&2 + exit 1 + fi +} + +test_resolver_applies_only_merge_targets() { + local expected="\${{ matrix.target.stack == 'terragrunt' && (github.event_name == 'pull_request' && 'plan' || (matrix.target.apply_trigger == 'merge' && 'apply' || '')) || '' }}" + local actual + actual="$(yq '.jobs["deploy-terragrunt"].with["action-type"]' "${workflows_dir}/reusable--label-base-deploy--resolver.yaml")" + assert_eq "${expected}" "${actual}" +} + +test_release_workflows_skip_empty_targets() { + local guard="needs.resolve.outputs.targets != '[]'" + assert_eq "${guard}" "$(yq '.jobs.plan.if' "${workflows_dir}/reusable--release--plan.yaml")" + assert_eq "${guard}" "$(yq '.jobs.apply.if' "${workflows_dir}/reusable--release--deploy.yaml")" +} + +test_release_deploy_declares_environment() { + local workflow="${workflows_dir}/reusable--release--deploy.yaml" + assert_eq '${{ matrix.target.environment }}' "$(yq '.jobs.apply.with["github-environment"]' "${workflow}")" + assert_eq '${{ needs.resolve.outputs.tag }}' "$(yq '.jobs.apply.with.ref' "${workflow}")" +} + +failed=0 +for test_name in $(compgen -A function test_); do + set +e + (set -e; "${test_name}") + status=$? + set -e + if [ "${status}" -eq 0 ]; then + echo "PASS: ${test_name}" + else + echo "FAIL: ${test_name}" + failed=1 + fi +done + +exit "${failed}" From 4271dfe69dfb9c08ba2ff26d957d3db094bc9f76 Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 04:04:05 +0900 Subject: [PATCH 08/12] fix: drop the unreachable release-please bootstrap sha This repository only allows squash merges, so the feature branch commit that bootstrap-sha pointed at never exists on main. Main has few commits, so release-please needs no lower bound for its first release. The tag reads in the deploy and build workflows now say why they take the dispatch input from the event. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .github/workflows/reusable--release--build.yaml | 1 + .github/workflows/reusable--release--deploy.yaml | 1 + release-please-config.json | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable--release--build.yaml b/.github/workflows/reusable--release--build.yaml index e1aa54c..ed05bff 100644 --- a/.github/workflows/reusable--release--build.yaml +++ b/.github/workflows/reusable--release--build.yaml @@ -24,6 +24,7 @@ jobs: - name: Parse tag id: parse env: + # `inputs` in a reusable workflow holds only its own workflow_call inputs, so the caller's dispatch input `tag` is read from the event. TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} run: | set -euo pipefail diff --git a/.github/workflows/reusable--release--deploy.yaml b/.github/workflows/reusable--release--deploy.yaml index 1163351..e53e9c6 100644 --- a/.github/workflows/reusable--release--deploy.yaml +++ b/.github/workflows/reusable--release--deploy.yaml @@ -20,6 +20,7 @@ jobs: permissions: contents: read env: + # `inputs` in a reusable workflow holds only its own workflow_call inputs, so the caller's dispatch input `tag` is read from the event. TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} outputs: targets: ${{ steps.resolve.outputs.targets }} diff --git a/release-please-config.json b/release-please-config.json index cde57b8..091330f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,7 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "separate-pull-requests": true, - "bootstrap-sha": "e984aa716d88f67aef9dde7c6780dc9e72c621a3", "changelog-sections": [ { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, From 8c57757389d881e620e569b55dda5ec2f617e8be Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 04:06:39 +0900 Subject: [PATCH 09/12] feat: add the shared maintenance and review workflows Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .../reusable--cleanup-container-image.yaml | 70 ++++++ .../workflows/reusable--docs-sync-check.yaml | 222 ++++++++++++++++++ .../reusable--renovate-major-review.yaml | 122 ++++++++++ 3 files changed, 414 insertions(+) create mode 100644 .github/workflows/reusable--cleanup-container-image.yaml create mode 100644 .github/workflows/reusable--docs-sync-check.yaml create mode 100644 .github/workflows/reusable--renovate-major-review.yaml diff --git a/.github/workflows/reusable--cleanup-container-image.yaml b/.github/workflows/reusable--cleanup-container-image.yaml new file mode 100644 index 0000000..6c46c93 --- /dev/null +++ b/.github/workflows/reusable--cleanup-container-image.yaml @@ -0,0 +1,70 @@ +# Deletes stale pr- and sha- tags and untagged images of the given service containers in ghcr.io. +name: 'Reusable - Cleanup Container Images' + +on: + workflow_call: + inputs: + services: + required: true + type: string + description: 'JSON array of service names whose containers are cleaned (e.g. ["monolith","frontend"])' + +jobs: + cleanup-container: + name: 'Cleanup ${{ matrix.service }}' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + service: ${{ fromJson(inputs.services) }} + fail-fast: false + steps: + - name: Clean old PR tags + uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + package: ${{ github.event.repository.name }}/${{ matrix.service }} + delete-tags: ^pr- + older-than: 1 days + exclude-tags: ^(latest|v\d+\.\d+\.\d+.*)$ + use-regex: true + validate: true + + - name: Clean SHA tags + uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + package: ${{ github.event.repository.name }}/${{ matrix.service }} + delete-tags: ^sha- + exclude-tags: ^(latest|v\d+\.\d+\.\d+.*)$ + use-regex: true + validate: true + + - name: Clean untagged images + uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + package: ${{ github.event.repository.name }}/${{ matrix.service }} + keep-n-untagged: 0 + delete-orphaned-images: true + delete-ghost-images: true + delete-partial-images: true + exclude-tags: ^(latest|v\d+\.\d+\.\d+.*)$ + use-regex: true + validate: true + + summary: + name: 'Cleanup Summary' + needs: cleanup-container + if: always() + runs-on: ubuntu-latest + steps: + - name: Summary + env: + SERVICES: ${{ inputs.services }} + run: | + echo "📊 Cleanup Summary" + echo "Services: ${SERVICES}" + echo "Cleanup job status: ${{ needs.cleanup-container.result }}" diff --git a/.github/workflows/reusable--docs-sync-check.yaml b/.github/workflows/reusable--docs-sync-check.yaml new file mode 100644 index 0000000..3024826 --- /dev/null +++ b/.github/workflows/reusable--docs-sync-check.yaml @@ -0,0 +1,222 @@ +# Judges with Claude whether a pull request keeps the docs in sync and reports the verdict as the Docs Sync Check commit status. +name: 'Reusable - Docs Sync Check' + +on: + workflow_call: + inputs: + scope-pattern: + required: true + type: string + description: 'Extended regex matching the changed paths whose docs are checked (e.g. ^dystopia/)' + prompt-path: + required: true + type: string + description: 'Path of the prompt file in the calling repository (e.g. .github/prompts/docs-sync-check.md)' + +jobs: + scope: + name: 'Scope' + # Fork pull requests only get a read-only token, so the status could not be written. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + statuses: write + outputs: + mode: ${{ steps.decide.outputs.mode }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + 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: Test decide script + run: bash .shared-workflows/scripts/docs-sync-check/decide.test.sh + + - name: Decide + id: decide + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + SCOPE_PATTERN: ${{ inputs.scope-pattern }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_LABEL: ${{ github.event.label.name }} + APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'docs-check-approved') }} + AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + BODY_OR_BASE_CHANGED: ${{ (github.event.changes.body || github.event.changes.base) && 'true' || 'false' }} + run: | + approved="$APPROVED" + prior_state="" + if [ "$EVENT_ACTION" = "labeled" ] && [ "$EVENT_LABEL" = "docs-check-approved" ]; then + # The payload labels are stale when this run starts after another run already removed the label. + approved="$(gh api "repos/$REPOSITORY/issues/$PR_NUMBER/labels?per_page=100" --jq 'any(.[]; .name == "docs-check-approved")')" + prior_state="$(gh api "repos/$REPOSITORY/commits/$HEAD_SHA/status?per_page=100" --jq '[.statuses[] | select(.context == "Docs Sync Check") | .state][0] // ""')" + fi + gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[] | .filename, (.previous_filename // empty)' | APPROVED="$approved" PRIOR_STATE="$prior_state" bash .shared-workflows/scripts/docs-sync-check/decide.sh >> "$GITHUB_OUTPUT" + + - name: Remove approval label + if: steps.decide.outputs.remove_label == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if ! output="$(gh api -X DELETE "repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/docs-check-approved" 2>&1)"; then + # SILENT: a 404 means someone already removed the label, which is the state this step wants. + if grep -q 'HTTP 404' <<<"$output"; then exit 0; fi + echo "$output" >&2 + exit 1 + fi + + - name: Write status + if: steps.decide.outputs.mode != 'ignore' + env: + GH_TOKEN: ${{ github.token }} + MODE: ${{ steps.decide.outputs.mode }} + REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + case "$MODE" in + skip-bot) state=success; description='bot の PR は判定しません' ;; + skip-unchanged) state=success; description='対象のパスに変更がありません' ;; + approved) state=success; description='docs-check-approved ラベルで承認されています' ;; + judge) state=pending; description='Claude が判定しています' ;; + *) echo "unknown mode: $MODE" >&2; exit 1 ;; + esac + gh api "repos/${REPOSITORY}/statuses/${HEAD_SHA}" \ + -f state="$state" \ + -f context='Docs Sync Check' \ + -f description="$description" \ + -f target_url="$RUN_URL" + + - name: Write failure status + if: ${{ failure() && steps.decide.outputs.mode != 'ignore' }} + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh api "repos/${REPOSITORY}/statuses/${HEAD_SHA}" \ + -f state=failure \ + -f context='Docs Sync Check' \ + -f description='判定できませんでした。再実行するか、確かめて docs-check-approved を付けてください' \ + -f target_url="$RUN_URL" + + judge: + name: 'Judge' + needs: scope + if: needs.scope.outputs.mode == 'judge' + runs-on: ubuntu-latest + timeout-minutes: 30 + # Environment-scoped OIDC sub keeps a prompt-injected run from assuming the plan roles, which trust the pull_request sub. + environment: docs-sync-check + permissions: + contents: read + pull-requests: read + id-token: write + outputs: + passed: ${{ steps.claude.outputs.structured_output && fromJSON(steps.claude.outputs.structured_output).passed || '' }} + body: ${{ steps.claude.outputs.structured_output && fromJSON(steps.claude.outputs.structured_output).body || '' }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: arn:aws:iam::559744160976:role/github-oidc-auth-master-github-actions-bedrock-role + aws-region: us-east-1 + + - name: Judge with Claude + id: claude + uses: anthropics/claude-code-action@16f9f71ab9c490faf367f94dd294897edc25be33 # v1.0.261 + env: + # The Bedrock role only allows Sonnet 4.6, so background tasks must not fall back to Haiku. + ANTHROPIC_DEFAULT_HAIKU_MODEL: global.anthropic.claude-sonnet-4-6 + with: + use_bedrock: 'true' + # Read-only token; without it the action exchanges OIDC for the Claude App's write token. + github_token: ${{ github.token }} + prompt: | + Read `${{ inputs.prompt-path }}` and follow it. + Pull request number: ${{ github.event.pull_request.number }} + claude_args: | + --model global.anthropic.claude-sonnet-4-6 + --json-schema '{"type":"object","properties":{"passed":{"type":"boolean"},"body":{"type":"string","minLength":1,"maxLength":60000}},"required":["passed","body"]}' + --allowedTools "Read,Glob,Grep,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*)" + + - name: Upload execution log + if: ${{ !cancelled() && steps.claude.outputs.execution_file != '' }} + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: docs-sync-check-execution + path: ${{ steps.claude.outputs.execution_file }} + retention-days: 7 + + report: + name: 'Report' + needs: [scope, judge] + # Runs after a failed judge too so Scope's pending status gets a verdict; a cancelled run writes nothing and leaves it pending. + if: ${{ !cancelled() && needs.scope.outputs.mode == 'judge' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pull-requests: write + statuses: write + steps: + - name: Upsert judge comment + if: needs.judge.outputs.body != '' + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + message: ${{ needs.judge.outputs.body }} + comment-tag: docs-sync-check + mode: upsert + pr-number: ${{ github.event.pull_request.number }} + + - name: Write status + env: + GH_TOKEN: ${{ github.token }} + JUDGE_RESULT: ${{ needs.judge.result }} + PASSED: ${{ needs.judge.outputs.passed }} + HAS_BODY: ${{ needs.judge.outputs.body != '' }} + REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ "$JUDGE_RESULT" != "success" ] || [ "$HAS_BODY" != "true" ]; then + state=failure + description='判定できませんでした。再実行するか、確かめて docs-check-approved を付けてください' + elif [ "$PASSED" = "true" ]; then + state=success + description='文書と食い違いはありません' + else + state=failure + description='文書との食い違いがあります。PR のコメントを見てください' + fi + gh api "repos/${REPOSITORY}/statuses/${HEAD_SHA}" \ + -f state="$state" \ + -f context='Docs Sync Check' \ + -f description="$description" \ + -f target_url="$RUN_URL" diff --git a/.github/workflows/reusable--renovate-major-review.yaml b/.github/workflows/reusable--renovate-major-review.yaml new file mode 100644 index 0000000..a299a8f --- /dev/null +++ b/.github/workflows/reusable--renovate-major-review.yaml @@ -0,0 +1,122 @@ +# Reviews Renovate major-update pull requests with Claude on Bedrock and posts the review. +name: 'Reusable - Renovate Major Review' + +on: + workflow_call: + inputs: + prompt-path: + required: true + type: string + description: 'Path of the prompt file in the calling repository (e.g. .github/prompts/renovate-major-review.md)' + +jobs: + render: + name: 'Render' + if: github.event.label.name == '📈 major-update' && github.event.pull_request.user.login == 'renovate[bot]' + runs-on: ubuntu-latest + timeout-minutes: 15 + # helmfile templates can exec commands, so PR content is rendered in a job without id-token or the review environment. + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + 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: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Render manifest diff + # HEAD is the PR merge commit, so its first parent is the base it merges into. + run: bash .shared-workflows/scripts/kubernetes-hydrate/render-diff.sh HEAD^1 "${RUNNER_TEMP}/render-diff" + + - name: Upload manifest diff + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: render-diff + path: ${{ runner.temp }}/render-diff + retention-days: 1 + + review: + name: 'Review' + needs: render + runs-on: ubuntu-latest + timeout-minutes: 30 + # Environment-scoped OIDC sub keeps a prompt-injected run from assuming the plan roles, which trust the pull_request sub. + environment: renovate-major-review + permissions: + contents: read + pull-requests: read + id-token: write + outputs: + body: ${{ steps.claude.outputs.structured_output && fromJSON(steps.claude.outputs.structured_output).body || '' }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download manifest diff + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: render-diff + path: .render-diff + + - name: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: arn:aws:iam::559744160976:role/github-oidc-auth-master-github-actions-bedrock-role + aws-region: us-east-1 + + - name: Review with Claude + id: claude + uses: anthropics/claude-code-action@16f9f71ab9c490faf367f94dd294897edc25be33 # v1.0.261 + env: + # The Bedrock role only allows Sonnet 4.6, so background tasks must not fall back to Haiku. + ANTHROPIC_DEFAULT_HAIKU_MODEL: global.anthropic.claude-sonnet-4-6 + with: + use_bedrock: 'true' + # Read-only token; without it the action exchanges OIDC for the Claude App's write token. + github_token: ${{ github.token }} + allowed_bots: renovate + prompt: | + Read `${{ inputs.prompt-path }}` and follow it. + Pull request number: ${{ github.event.pull_request.number }} + claude_args: | + --model global.anthropic.claude-sonnet-4-6 + --json-schema '{"type":"object","properties":{"body":{"type":"string","minLength":1,"maxLength":60000}},"required":["body"]}' + --allowedTools "Read,Glob,Grep,WebFetch,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(helm repo add:*),Bash(helm repo update:*),Bash(helm pull:*),Bash(helm show:*)" + + post: + name: 'Post Review' + needs: review + if: needs.review.result == 'success' && needs.review.outputs.body != '' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Upsert review comment + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + message: ${{ needs.review.outputs.body }} + comment-tag: renovate-major-review + mode: upsert + pr-number: ${{ github.event.pull_request.number }} From 8624f488fd0e9d296b9ddbc655b9da1736f0995d Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 04:13:45 +0900 Subject: [PATCH 10/12] feat: add the shared renovate preset The three repositories carried the same Renovate settings, so the top-level options and the four package rules they share move here as default.json. Rules that only some repositories have, such as the OpenTofu tracking and the custom managers, stay in those repositories. References to this repository skip minimumReleaseAge because the shared workflows are released by us, and a wait would only delay picking up a release. This repository extends its own preset with local>, which github.com resolves like github>. The README now describes how the shared workflows are called and how the preset is extended. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .github/renovate.json | 6 ++++ README.md | 38 +++++++++++++++++++++ default.json | 77 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 .github/renovate.json create mode 100644 default.json diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..59c472c --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>panicboat/.github" + ] +} diff --git a/README.md b/README.md index 3519f07..faab4f3 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,41 @@ panicboat アカウントのリポジトリで共有する GitHub の設定を ここにある issue テンプレートと PR テンプレートは、自前のテンプレートを持たないリポジトリで既定として使われる。 リポジトリが自前の `.github/ISSUE_TEMPLATE/` を持つと、ここの issue テンプレートはフォルダごと使われない。 + +## Shared Workflows + +`.github/workflows/reusable--*.yaml` は、panicboat の他のリポジトリから呼び出す共通の workflow。 +呼び出し側は `uses: panicboat/.github/.github/workflows/@ # vX.Y.Z` と書いて、release の commit SHA に固定する。 +固定した SHA は Renovate が更新する。 + +```yaml +jobs: + lint: + permissions: + contents: read + uses: panicboat/.github/.github/workflows/reusable--lint-actions.yaml@ # vX.Y.Z +``` + +- 呼び出す job は、呼ばれる workflow が必要とする権限を `permissions` で付ける。 + - 複数の stack をまとめる workflow は job の `permissions` を宣言しない。 + - 呼び出し側が、そのリポジトリで使う stack の分だけを付ける。 +- 共通の workflow から別の共通の workflow を呼ぶときは、`uses: $/.github/workflows/` と書く。 + - `$/` は呼び出し元の workflow と同じ commit に解決される。 + - 入れ子の workflow が、呼び出し側の固定とずれない。 +- `scripts/` の script は、`job.workflow_repository` を `job.workflow_sha` で `.shared-workflows/` に checkout して実行する。 + - 呼び出し側が固定した workflow と同じ commit の script を使うため。 +- このリポジトリは public のままにする。 + - 呼び出し側が、自分の `GITHUB_TOKEN` でこのリポジトリを checkout するため。 + +## Renovate Preset + +`default.json` は、panicboat のリポジトリで共通の Renovate 設定を持つ。 +各リポジトリは `extends` にこの preset を書き、そのリポジトリ固有の rule だけを自分の `renovate.json` に残す。 + +- 他のリポジトリは `"extends": ["github>panicboat/.github"]` と書く。 + - Renovate の docs が、別のリポジトリに置いた preset の参照として示す書式。 +- このリポジトリは `"extends": ["local>panicboat/.github"]` と書く。 + - `local>` は、対象のリポジトリと同じ platform にある preset を参照する書式。 + - github.com では `github>` と同じに解決される。 +- `panicboat/.github` への参照は、`minimumReleaseAge` を待たずに更新する。 + - 共通の workflow は panicboat 自身が release するので、公開から日を置いて様子を見る理由がない。 diff --git a/default.json b/default.json new file mode 100644 index 0000000..cfd5f2e --- /dev/null +++ b/default.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":semanticCommits", + ":separateMajorReleases", + "group:monorepos", + "group:recommended", + "helpers:pinGitHubActionDigests" + ], + "semanticCommitScope": "{{packageFileDir}}", + "timezone": "Asia/Tokyo", + "labels": [ + "🤖 renovate" + ], + "assigneesFromCodeOwners": false, + "reviewersFromCodeOwners": true, + "automergeType": "pr", + "automergeStrategy": "squash", + "platformAutomerge": true, + "prConcurrentLimit": 15, + "prHourlyLimit": 5, + "branchPrefix": "renovate/", + "rangeStrategy": "bump", + "schedule": [ + "before 4am every weekday" + ], + "minimumReleaseAge": "1 day", + "separateMinorPatch": false, + "packageRules": [ + { + "description": "Default: separate PRs per package", + "groupName": "{{packageFileDir}}/{{depName}}", + "matchPackageNames": [ + "*" + ] + }, + { + "description": "🚫 Production environments - disable automerge", + "matchFileNames": [ + "**/production/**" + ], + "automerge": false, + "prPriority": -1, + "addLabels": [ + "⚠️ production" + ] + }, + { + "description": "🚫 Major updates - disable automerge", + "matchUpdateTypes": [ + "major" + ], + "automerge": false, + "addLabels": [ + "📈 major-update" + ] + }, + { + "description": "✅ Minor/Patch updates - enable automerge", + "matchUpdateTypes": [ + "minor", + "patch", + "pin", + "digest" + ], + "automerge": true + }, + { + "description": "Shared workflows from panicboat/.github apply without waiting for a release age", + "matchPackageNames": [ + "panicboat/.github" + ], + "minimumReleaseAge": null + } + ] +} From 42f3883aeeee8bed3288e057e56ff4d9c4f9788c Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 08:12:46 +0900 Subject: [PATCH 11/12] test: exercise nested shared workflow calls and the self checkout The shared workflows call each other with `$/` and run their scripts from a self checkout of `job.workflow_sha`. No pull request check ran either path, so a regression in them surfaced only in the consuming repositories. A test workflow chain now calls a reusable workflow through `$/` and asserts that the self checkout is the running commit and can run a script. A workflow test pins the `$/` call so it cannot silently become `./`. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .../test-shared-workflows--checkout.yaml | 39 +++++++++++++++++++ .../test-shared-workflows--nested.yaml | 9 +++++ .github/workflows/test-shared-workflows.yaml | 13 +++++++ scripts/workflows/test.sh | 5 +++ 4 files changed, 66 insertions(+) create mode 100644 .github/workflows/test-shared-workflows--checkout.yaml create mode 100644 .github/workflows/test-shared-workflows--nested.yaml create mode 100644 .github/workflows/test-shared-workflows.yaml diff --git a/.github/workflows/test-shared-workflows--checkout.yaml b/.github/workflows/test-shared-workflows--checkout.yaml new file mode 100644 index 0000000..55aa3f6 --- /dev/null +++ b/.github/workflows/test-shared-workflows--checkout.yaml @@ -0,0 +1,39 @@ +# Checks that the self checkout resolves to the running workflow commit and that its scripts run. +name: 'Test Shared Workflows - Checkout' + +on: + workflow_call: + +jobs: + self-checkout: + name: Self checkout + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - 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: Verify the self checkout is the running workflow commit + env: + WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + actual="$(git -C .shared-workflows rev-parse HEAD)" + if [ "${actual}" != "${WORKFLOW_SHA}" ]; then + echo "expected ${WORKFLOW_SHA}, got ${actual}" >&2 + exit 1 + fi + + - name: Run a script from the self checkout + run: | + actual="$(bash .shared-workflows/scripts/release-please/find-configs.sh)" + if [ "${actual}" != '["."]' ]; then + echo "expected [\".\"], got ${actual}" >&2 + exit 1 + fi diff --git a/.github/workflows/test-shared-workflows--nested.yaml b/.github/workflows/test-shared-workflows--nested.yaml new file mode 100644 index 0000000..711d47d --- /dev/null +++ b/.github/workflows/test-shared-workflows--nested.yaml @@ -0,0 +1,9 @@ +# Calls the self checkout test through `$/` the way the shared workflows call each other. +name: 'Test Shared Workflows - Nested' + +on: + workflow_call: + +jobs: + checkout: + uses: $/.github/workflows/test-shared-workflows--checkout.yaml diff --git a/.github/workflows/test-shared-workflows.yaml b/.github/workflows/test-shared-workflows.yaml new file mode 100644 index 0000000..f346dd9 --- /dev/null +++ b/.github/workflows/test-shared-workflows.yaml @@ -0,0 +1,13 @@ +# Exercises the nested reusable workflow call and the self checkout that the shared workflows rely on. +name: Test Shared Workflows + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + permissions: + contents: read + uses: ./.github/workflows/test-shared-workflows--nested.yaml diff --git a/scripts/workflows/test.sh b/scripts/workflows/test.sh index bdd0255..9d8f9da 100755 --- a/scripts/workflows/test.sh +++ b/scripts/workflows/test.sh @@ -33,6 +33,11 @@ test_release_deploy_declares_environment() { assert_eq '${{ needs.resolve.outputs.tag }}' "$(yq '.jobs.apply.with.ref' "${workflow}")" } +test_nested_test_workflow_uses_dollar_slash() { + local workflow="${workflows_dir}/test-shared-workflows--nested.yaml" + assert_eq '$/.github/workflows/test-shared-workflows--checkout.yaml' "$(yq '.jobs[].uses' "${workflow}")" +} + failed=0 for test_name in $(compgen -A function test_); do set +e From 20b4bbcc9b1a3818554b069db7c3d673dfc2cd3e Mon Sep 17 00:00:00 2001 From: panicboat Date: Sun, 25 Oct 2026 08:13:45 +0900 Subject: [PATCH 12/12] fix: compare the kustomize diff against the merge commit The kubernetes builder checked out `github.head_ref` for every stack, so the kustomize stack diffed the pull request head instead of the default merge commit against the base. Only the helmfile stack needs the branch head, because its hydrate job commits manifests to the pull request branch first. The builder now takes a `ref` input (empty uses the event's default ref) and the resolver passes `github.head_ref` from the helmfile job only. `fetch-depth: 0` is dropped because no step of the builder reads history. Claude-Session: https://claude.ai/code/session_01R7jQZQj1VyeBbYR7P1ssV7 Signed-off-by: panicboat --- .github/workflows/reusable--kubernetes-builder.yaml | 8 ++++++-- .../workflows/reusable--label-base-deploy--resolver.yaml | 2 ++ scripts/workflows/test.sh | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable--kubernetes-builder.yaml b/.github/workflows/reusable--kubernetes-builder.yaml index 5cc2c4b..b0a4717 100644 --- a/.github/workflows/reusable--kubernetes-builder.yaml +++ b/.github/workflows/reusable--kubernetes-builder.yaml @@ -16,6 +16,11 @@ on: required: true type: string description: 'Directory containing the kustomization.yaml to diff (skips the job when empty)' + ref: + required: false + type: string + default: '' + description: 'Git ref to check out for the head manifests (empty uses the default ref of the triggering event)' app-id: required: true type: string @@ -51,8 +56,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ steps.app-token.outputs.token }} - ref: ${{ github.head_ref }} - fetch-depth: 0 + ref: ${{ inputs.ref }} - name: Checkout base ref uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/reusable--label-base-deploy--resolver.yaml b/.github/workflows/reusable--label-base-deploy--resolver.yaml index eaf1e56..4a2a98d 100644 --- a/.github/workflows/reusable--label-base-deploy--resolver.yaml +++ b/.github/workflows/reusable--label-base-deploy--resolver.yaml @@ -182,6 +182,8 @@ jobs: service-name: ${{ matrix.target.service }} environment: ${{ matrix.target.environment }} kustomization-directory: ${{ matrix.target.stack == 'helmfile' && format('kubernetes/manifests/{0}/{1}', matrix.target.environment, matrix.target.service) || '' }} + # The hydrate job commits to the pull request branch, which the default merge ref does not contain. + ref: ${{ github.head_ref }} app-id: ${{ inputs.app-id }} secrets: private-key: ${{ secrets.private-key }} diff --git a/scripts/workflows/test.sh b/scripts/workflows/test.sh index 9d8f9da..09467cf 100755 --- a/scripts/workflows/test.sh +++ b/scripts/workflows/test.sh @@ -33,6 +33,14 @@ test_release_deploy_declares_environment() { assert_eq '${{ needs.resolve.outputs.tag }}' "$(yq '.jobs.apply.with.ref' "${workflow}")" } +test_kubernetes_builder_checks_out_head_ref_only_for_helmfile() { + local resolver="${workflows_dir}/reusable--label-base-deploy--resolver.yaml" + local builder="${workflows_dir}/reusable--kubernetes-builder.yaml" + assert_eq '${{ github.head_ref }}' "$(yq '.jobs["deploy-helmfile"].with.ref' "${resolver}")" + assert_eq 'null' "$(yq '.jobs["deploy-kustomize"].with.ref' "${resolver}")" + assert_eq '${{ inputs.ref }}' "$(yq '.jobs["kustomize-diff"].steps[] | select(.name == "Checkout") | .with.ref' "${builder}")" +} + test_nested_test_workflow_uses_dollar_slash() { local workflow="${workflows_dir}/test-shared-workflows--nested.yaml" assert_eq '$/.github/workflows/test-shared-workflows--checkout.yaml' "$(yq '.jobs[].uses' "${workflow}")"