name: Generic Container CI/CD on: workflow_call: inputs: image_name: description: Fully qualified Gitea Registry image repository without a tag required: true type: string dockerfile: required: false default: Dockerfile type: string build_context: required: false default: . type: string build_args: description: Optional newline-separated Docker build arguments in KEY=VALUE form required: false default: "" type: string cache_image: description: Optional internal image carrying BuildKit inline cache required: false default: "" type: string test_command: description: Optional source-level test command required: false default: "" type: string test_target: description: Optional Dockerfile target that performs build-time tests required: false default: "" type: string deploy_service: description: Dev service to candidate-test and promote required: false default: "" type: string deploy_host: description: Restricted Dev deployment host required: false default: "" type: string release_retention_count: description: Number of immutable sha-* release images to retain after a successful promotion required: false default: 2 type: number secrets: REGISTRY_USERNAME: required: true REGISTRY_PASSWORD: required: true DEV_DEPLOY_SSH_KEY: required: false jobs: test-build-publish: runs-on: ubuntu-22.04 steps: - name: Configure Git transport run: | git config --global http.version HTTP/1.1 # Runner 与 Gitea 同处内网。绕过公网 Nginx 的 Git pack 转发,避免 # 大仓库检出时 TLS 流被中断;对外访问仍使用 ROOT_URL。 git config --global url."http://192.168.1.112:3333/".insteadOf "https://gitea.waternetwork.cn/" - uses: https://gitea.waternetwork.cn/actions/checkout@v4 with: fetch-depth: 1 - name: Source tests if: ${{ inputs.test_command != '' }} run: ${{ inputs.test_command }} - name: Login and publish candidate image env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | set -eu registry="$(printf '%s' '${{ inputs.image_name }}' | cut -d/ -f1)" printf '%s' "$REGISTRY_PASSWORD" | docker login "$registry" -u "$REGISTRY_USERNAME" --password-stdin if [ -n "${{ inputs.cache_image }}" ] && ! docker image inspect "${{ inputs.cache_image }}" >/dev/null 2>&1; then echo "Required CI cache image is not present on this Runner: ${{ inputs.cache_image }}" echo "Import the cache image locally before starting this workflow." exit 1 fi - name: Build immutable candidate image run: | set -eu cache_args=() build_args=() if [ -n "${{ inputs.cache_image }}" ]; then cache_args+=(--cache-from "${{ inputs.cache_image }}") fi while IFS= read -r build_arg || [ -n "$build_arg" ]; do build_arg="$(printf '%s' "$build_arg" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" [ -z "$build_arg" ] && continue build_arg_key="${build_arg%%=*}" case "$build_arg_key" in ''|[0-9]*|*[!A-Z0-9_]*) echo "Invalid Docker build argument key: $build_arg_key" exit 1 ;; esac case "$build_arg" in *=*) build_args+=(--build-arg "$build_arg") ;; *) echo "Docker build arguments must use KEY=VALUE form" exit 1 ;; esac done <<'BUILD_ARGS' ${{ inputs.build_args }} BUILD_ARGS if [ -n "${{ inputs.test_target }}" ]; then docker build --pull=false "${cache_args[@]}" "${build_args[@]}" --target "${{ inputs.test_target }}" \ -f "${{ inputs.dockerfile }}" "${{ inputs.build_context }}" fi docker build --pull=false "${cache_args[@]}" "${build_args[@]}" -f "${{ inputs.dockerfile }}" \ -t "${{ inputs.image_name }}:sha-${{ gitea.sha }}" "${{ inputs.build_context }}" docker push "${{ inputs.image_name }}:sha-${{ gitea.sha }}" - name: Configure restricted Dev deployment key if: ${{ inputs.deploy_service != '' && inputs.deploy_host != '' }} env: DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }} run: | set -eu test -n "$DEV_DEPLOY_SSH_KEY" install -d -m 700 ~/.ssh printf '%s\n' "$DEV_DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H "${{ inputs.deploy_host }}" >> ~/.ssh/known_hosts - name: Transfer candidate image to Dev if: ${{ inputs.deploy_service != '' && inputs.deploy_host != '' }} run: | set -euo pipefail docker save "${{ inputs.image_name }}:sha-${{ gitea.sha }}" | \ ssh -i ~/.ssh/id_ed25519 cicd-deploy@"${{ inputs.deploy_host }}" \ "load ${{ inputs.deploy_service }} ${{ inputs.image_name }}:sha-${{ gitea.sha }}" - name: Candidate verification on Dev if: ${{ inputs.deploy_service != '' && inputs.deploy_host != '' }} run: | set -eu ssh -i ~/.ssh/id_ed25519 cicd-deploy@"${{ inputs.deploy_host }}" \ "candidate ${{ inputs.deploy_service }} ${{ inputs.image_name }}:sha-${{ gitea.sha }}" - name: Promote tagged release after candidate check if: ${{ startsWith(gitea.ref, 'refs/tags/v') && inputs.deploy_service != '' && inputs.deploy_host != '' }} env: DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }} run: | set -eu ssh -i ~/.ssh/id_ed25519 cicd-deploy@"${{ inputs.deploy_host }}" \ "promote ${{ inputs.deploy_service }} ${{ inputs.image_name }}:sha-${{ gitea.sha }}" - name: Retain current and previous release images if: ${{ startsWith(gitea.ref, 'refs/tags/v') && inputs.deploy_service != '' && inputs.deploy_host != '' }} env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | set -euo pipefail image_name='${{ inputs.image_name }}' keep_count='${{ inputs.release_retention_count }}' current_version='sha-${{ gitea.sha }}' case "$keep_count" in ''|*[!0-9]*|0) echo "release_retention_count must be a positive integer" exit 1 ;; esac registry="${image_name%%/*}" image_path="${image_name#*/}" owner="${image_path%%/*}" package_name="${image_path#*/}" if [ "$registry" = "$image_name" ] || [ "$owner" = "$image_path" ]; then echo "image_name must include registry, owner, and package name" exit 1 fi case "$owner/$package_name" in *[!A-Za-z0-9._/-]*|*/*/*) echo "image owner or package name contains unsupported characters" exit 1 ;; esac api_base="https://${registry}/api/v1/packages" versions_file="$(mktemp)" keep_file="$(mktemp)" trap 'rm -f "$versions_file" "$keep_file"' EXIT page=1 while [ "$page" -le 100 ]; do response="$(curl --fail --silent --show-error --retry 3 --retry-all-errors \ --user "$REGISTRY_USERNAME:$REGISTRY_PASSWORD" \ "${api_base}/${owner}?type=container&q=${package_name}&limit=50&page=${page}")" item_count="$(printf '%s' "$response" | jq 'length')" [ "$item_count" -gt 0 ] || break printf '%s' "$response" | jq -r --arg package_name "$package_name" ' .[] | select((.name | ascii_downcase) == ($package_name | ascii_downcase)) | select(.version | test("^sha-[0-9a-f]{40}$")) | [.created_at, .version] | @tsv ' >> "$versions_file" page=$((page + 1)) done if ! awk -F '\t' -v current="$current_version" '$2 == current { found = 1 } END { exit(found ? 0 : 1) }' "$versions_file"; then echo "Current release version was not returned by the Gitea Packages API: $current_version" exit 1 fi printf '%s\n' "$current_version" > "$keep_file" previous_count=$((keep_count - 1)) if [ "$previous_count" -gt 0 ]; then sort -r "$versions_file" | awk -F '\t' \ -v current="$current_version" \ -v limit="$previous_count" \ '$2 != current && kept < limit { print $2; kept++ }' >> "$keep_file" fi deleted=0 while IFS=$'\t' read -r _created_at version; do [ -n "$version" ] || continue if grep -Fqx "$version" "$keep_file"; then echo "Keeping release image: $package_name:$version" continue fi curl --fail --silent --show-error --retry 3 --retry-all-errors \ --user "$REGISTRY_USERNAME:$REGISTRY_PASSWORD" \ --request DELETE \ "${api_base}/${owner}/container/${package_name}/${version}" echo "Deleted superseded release image: $package_name:$version" deleted=$((deleted + 1)) done < <(sort -r "$versions_file") echo "Release retention complete: package=$package_name retained=$keep_count deleted=$deleted"