Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/actions/build/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# This file is centrally managed; local edits may be lost.

name: Standard .NET build
description: Build, test, and package a conventional .NET repository without publishing

# Contract:
# - CI owns the fixed .artifacts layout; this action deliberately has no path inputs.
# - The repository must be buildable from its root with the dotnet CLI.
# - .ci-skip-tests remains a temporary escape hatch for repositories without tests.
# - Effective IsPackable projects write packages to the fixed NuGet directory.
# - This action never receives credentials and never publishes anything.

runs:
using: composite
steps:
- name: Set up .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: 10.0.x

- name: Restore
shell: bash
run: dotnet restore

- name: Build
shell: bash
run: |
set -euo pipefail
version_args=()
if [ -n "${CI_VERSION:-}" ]; then
version_args+=("-p:Version=$CI_VERSION")
fi

dotnet build --configuration Release --no-restore "${version_args[@]}"

- name: Test
if: hashFiles('.ci-skip-tests') == ''
shell: bash
run: dotnet test --configuration Release --no-build --verbosity normal

- name: Pack effective IsPackable projects
if: env.CI_PUBLISH_TARGET != 'none'
shell: bash
run: |
set -euo pipefail
mkdir -p .artifacts/nuget

version_args=()
if [ -n "${CI_VERSION:-}" ]; then
version_args+=("-p:Version=$CI_VERSION")
fi

dotnet pack \
--configuration Release \
--no-build \
--output .artifacts/nuget \
"${version_args[@]}"
101 changes: 101 additions & 0 deletions .github/actions/collect_publishables/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# This file is centrally managed; local edits may be lost.

name: Collect publishable outputs
description: Detect package and Docker outputs without publishing

# Contract:
# - Package presence, not project inspection, selects package publication.
# - Dockerfile presence, not repository configuration, selects Docker publication.
# - This action never builds Docker images and never receives credentials.

outputs:
has-packages:
value: ${{ steps.packages.outputs.has-packages }}
has-docker:
value: ${{ steps.docker.outputs.has-docker }}

runs:
using: composite
steps:
- name: Detect package artifacts
id: packages
shell: bash
run: |
set -euo pipefail
shopt -s nullglob

packages=(.artifacts/nuget/*.nupkg)
has_packages=false
if [ "${#packages[@]}" -gt 0 ]; then
has_packages=true
fi

echo "has-packages=$has_packages" >> "$GITHUB_OUTPUT"

- name: Discover Dockerfiles
id: docker
shell: bash
run: |
set -euo pipefail

metadata=.artifacts/docker-images.json
printf '[]\n' > "$metadata"

mapfile -d '' dockerfiles < <(
find . -type f \( -name Dockerfile -o -name 'Dockerfile.*' \) \
-not -path './.git/*' \
-not -path './.artifacts/*' \
-not -path '*/node_modules/*' \
-not -path '*/bin/*' \
-not -path '*/obj/*' \
-print0 \
| sort -z
)

if [ "${#dockerfiles[@]}" -eq 0 ]; then
echo 'has-docker=false' >> "$GITHUB_OUTPUT"
exit 0
fi

configured_dockerfile=''
configured_image=''
configured_platforms=''
if [ -f .env ]; then
configured_dockerfile="$(sed -nE 's/^DOCKER_DOCKERFILE=(.+)\r?$/\1/p' .env | tail -n1)"
configured_image="$(sed -nE 's/^DOCKER_TAG=(.+)\r?$/\1/p' .env | tail -n1)"
configured_platforms="$(sed -nE 's/^DOCKER_PLATFORMS=(.+)\r?$/\1/p' .env | tail -n1)"
fi

repository_image="ghcr.io/${GITHUB_REPOSITORY,,}"
for dockerfile_path in "${dockerfiles[@]}"; do
dockerfile="${dockerfile_path#./}"
if [ -n "$configured_dockerfile" ] && [ -n "$configured_image" ] && [ "$dockerfile" = "$configured_dockerfile" ]; then
image="$configured_image"
platforms="${configured_platforms:-linux/amd64}"
else
filename="$(basename "$dockerfile")"
suffix=''
if [[ "$filename" == Dockerfile.* ]]; then
suffix="-${filename#Dockerfile.}"
elif [ "${#dockerfiles[@]}" -gt 1 ]; then
directory="$(dirname "$dockerfile")"
if [ "$directory" != . ]; then
suffix="-$(printf '%s' "$directory" | tr '/_' '--')"
fi
fi

image="${repository_image}${suffix,,}"
platforms=linux/amd64
fi

jq \
--arg dockerfile "$dockerfile" \
--arg image "$image" \
--arg platforms "$platforms" \
'. + [{dockerfile:$dockerfile,context:".",image:$image,platforms:$platforms}]' \
"$metadata" > "$metadata.next"
mv "$metadata.next" "$metadata"
done

echo 'has-docker=true' >> "$GITHUB_OUTPUT"
jq -r '.[] | "Docker: \(.dockerfile) -> \(.image) [\(.platforms)]"' "$metadata"
113 changes: 113 additions & 0 deletions .github/actions/initialize_ci/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# This file is centrally managed; local edits may be lost.

name: Initialize CI
description: Resolve the trusted ref context and prepare the fixed CI artifact layout

# Contract:
# - This is the sole interpreter of branch and version semantics.
# - It exports CI_* variables for later steps and matching action outputs for jobs.
# - It owns the fixed .artifacts layout and provenance schema.

outputs:
publish-target:
value: ${{ steps.context.outputs.publish-target }}
release-tag:
value: ${{ steps.context.outputs.release-tag }}
version:
value: ${{ steps.context.outputs.version }}
is-release:
value: ${{ steps.context.outputs.is-release }}
is-prerelease:
value: ${{ steps.context.outputs.is-prerelease }}
is-plain-version:
value: ${{ steps.context.outputs.is-plain-version }}

runs:
using: composite
steps:
- name: Resolve ref and version
id: context
shell: bash
env:
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
CI_EVENT_NAME: ${{ github.event_name }}
CI_REPOSITORY: ${{ github.repository }}
CI_RUN_ID: ${{ github.run_id }}
CI_RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
set -euo pipefail

is_release=false
is_prerelease=false
is_plain_version=false
release_tag=''
version=''
if [[ "$GITHUB_REF" == refs/tags/* ]] && printf '%s' "$GITHUB_REF_NAME" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
is_release=true
release_tag="$GITHUB_REF_NAME"
version="${GITHUB_REF_NAME#v}"
if [[ "$version" == *-* ]]; then
is_prerelease=true
fi
if printf '%s' "$release_tag" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
is_plain_version=true
fi
fi

publish_target=none
if [ "$CI_EVENT_NAME" = push ] && [ "$GITHUB_REF" = "refs/heads/$CI_DEFAULT_BRANCH" ]; then
publish_target=development
elif [ "$CI_EVENT_NAME" = push ] && [ "$is_release" = true ]; then
publish_target=release
fi

mkdir -p .artifacts/nuget .artifacts/release .artifacts/deploy

jq -n \
--arg repository "$CI_REPOSITORY" \
--arg event "$CI_EVENT_NAME" \
--arg sha "$GITHUB_SHA" \
--arg ref "$GITHUB_REF" \
--arg ref_name "$GITHUB_REF_NAME" \
--arg default_branch "$CI_DEFAULT_BRANCH" \
--arg release_tag "$release_tag" \
--arg version "$version" \
--argjson run_id "$CI_RUN_ID" \
--argjson run_attempt "$CI_RUN_ATTEMPT" \
--argjson is_release "$is_release" \
--argjson is_prerelease "$is_prerelease" \
--argjson is_plain_version "$is_plain_version" \
'{
schemaVersion: 1,
repository: $repository,
event: $event,
sha: $sha,
ref: $ref,
refName: $ref_name,
defaultBranch: $default_branch,
runId: $run_id,
runAttempt: $run_attempt,
releaseTag: $release_tag,
version: $version,
isRelease: $is_release,
isPrerelease: $is_prerelease,
isPlainVersion: $is_plain_version
}' > .artifacts/ci-metadata.json

{
echo "CI_PUBLISH_TARGET=$publish_target"
echo "CI_RELEASE_TAG=$release_tag"
echo "CI_VERSION=$version"
echo "CI_IS_RELEASE=$is_release"
echo "CI_IS_PRERELEASE=$is_prerelease"
echo "CI_IS_PLAIN_VERSION=$is_plain_version"
} >> "$GITHUB_ENV"

{
echo "publish-target=$publish_target"
echo "release-tag=$release_tag"
echo "version=$version"
echo "is-release=$is_release"
echo "is-prerelease=$is_prerelease"
echo "is-plain-version=$is_plain_version"
} >> "$GITHUB_OUTPUT"
99 changes: 99 additions & 0 deletions .github/actions/publish_docker/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# This file is centrally managed; local edits may be lost.

name: Publish Docker images
description: Build and publish every Docker image described by CI metadata

# Contract:
# - Image definitions are read only from .artifacts/docker-images.json.
# - Each image is built exactly once in this publishing job.
# - Development refs publish :dev; releases publish :<version> and stable :latest.
# - Every build receives BUILD_VERSION=dev or the normalized release version.
# - Registry credentials are supplied only by the invoking publishing job.

inputs:
publish-target:
description: CI publish target (development or release)
required: true
version:
description: Normalized release version, empty for development
required: false
default: ''
is-prerelease:
description: Whether the release version is a prerelease
required: true

runs:
using: composite
steps:
- name: Detect required registries
id: registries
shell: bash
run: |
set -euo pipefail

has_ghcr="$(jq 'any(.[]; .image | startswith("ghcr.io/"))' .artifacts/docker-images.json)"
has_docker_hub="$(jq 'any(.[]; (.image | startswith("ghcr.io/") | not))' .artifacts/docker-images.json)"
{
echo "has-ghcr=$has_ghcr"
echo "has-docker-hub=$has_docker_hub"
} >> "$GITHUB_OUTPUT"

- name: Log in to GHCR
if: steps.registries.outputs.has-ghcr == 'true'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ env.DOCKER_GITHUB_TOKEN }}

- name: Log in to Docker Hub
if: steps.registries.outputs.has-docker-hub == 'true'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
with:
username: ${{ env.DOCKER_USERNAME }}
password: ${{ env.DOCKER_KEY }}

- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4

- name: Build and publish Docker images
shell: bash
env:
PUBLISH_TARGET: ${{ inputs.publish-target }}
VERSION: ${{ inputs.version }}
IS_PRERELEASE: ${{ inputs.is-prerelease }}
run: |
set -euo pipefail

while IFS= read -r image_json; do
dockerfile="$(jq -r '.dockerfile' <<< "$image_json")"
context="$(jq -r '.context' <<< "$image_json")"
image="$(jq -r '.image' <<< "$image_json")"
platforms="$(jq -r '.platforms' <<< "$image_json")"
cache_scope="$(printf '%s' "$image" | tr '/:.' '---')"

tag_args=()
if [ "$PUBLISH_TARGET" = development ]; then
build_version=dev
tag_args+=(--tag "$image:dev")
else
build_version="$VERSION"
tag_args+=(--tag "$image:$VERSION")
if [ "$IS_PRERELEASE" = false ]; then
tag_args+=(--tag "$image:latest")
fi
fi

docker buildx build \
--file "$dockerfile" \
--platform "$platforms" \
"${tag_args[@]}" \
--build-arg "BUILD_VERSION=$build_version" \
--cache-from "type=gha,scope=$cache_scope" \
--cache-to "type=gha,mode=max,scope=$cache_scope" \
--push \
"$context"
done < <(jq -c '.[]' .artifacts/docker-images.json)
Loading
Loading