CI #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| schedule: | |
| # Weekly. These examples exist to work, and the way they stop working is a | |
| # base image or a registry changing under them — which no PR would catch. | |
| - cron: "17 6 * * 1" | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| discover: | |
| name: Discover examples | |
| runs-on: ubuntu-latest | |
| outputs: | |
| examples: ${{ steps.find.outputs.examples }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - id: find | |
| # Any top-level directory carrying a Dockerfile is an example. | |
| run: | | |
| examples=$(find . -maxdepth 2 -name Dockerfile -printf '%h\n' \ | |
| | sed 's|^\./||' | sort | jq -R . | jq -sc .) | |
| echo "examples=$examples" >> "$GITHUB_OUTPUT" | |
| echo "Found: $examples" | |
| build: | |
| name: Build ${{ matrix.example }} | |
| needs: discover | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| example: ${{ fromJSON(needs.discover.outputs.examples) }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: docker/setup-buildx-action@v3 | |
| - name: Build image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: ./${{ matrix.example }} | |
| push: false | |
| load: true | |
| tags: musher-examples/${{ matrix.example }}:ci | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| - name: Container starts and answers its readiness probe | |
| run: | | |
| set -euo pipefail | |
| example="${{ matrix.example }}" | |
| # An example may declare several Components; only the ones whose | |
| # image ref is the ${IMAGE_REF} placeholder are built from this | |
| # Dockerfile. Probe the first of those that declares a readiness | |
| # path — the rest wrap public images CI has no business starting. | |
| spec="" | |
| for f in "$example"/musher/component*.yaml; do | |
| ref=$(yq -r '.spec.workload.source.ref // ""' "$f") | |
| case "$ref" in '$'*) ;; *) continue ;; esac | |
| if yq -e '.spec.workload.health.readiness.path' "$f" >/dev/null 2>&1; then | |
| spec="$f"; break | |
| fi | |
| done | |
| probe="" | |
| [ -n "$spec" ] && probe=$(yq -r '.spec.workload.health.readiness.path' "$spec") | |
| if [ -z "$probe" ]; then | |
| echo "no readiness probe declared — start-only check" | |
| docker run --rm -d --name "ci-$example" "musher-examples/$example:ci" | |
| sleep 3 | |
| docker ps --filter "name=ci-$example" --format '{{.Names}}' | grep -q "ci-$example" | |
| docker rm -f "ci-$example" | |
| exit 0 | |
| fi | |
| port=$(yq -r '.spec.workload.endpoints.primary.containerPort' "$spec") | |
| bp="$example/musher/blueprint.yaml" | |
| net="ci-$example-net" | |
| docker network create "$net" >/dev/null | |
| # Stand in for the platform's wiring. Every Component that wraps a | |
| # public image starts as a sidecar named after its Blueprint node, | |
| # and each CONNECTION input on the probed Component is resolved the | |
| # way Musher resolves it: take the producer's output template and | |
| # substitute the producer's address for {{ self.privateAddress.* }}. | |
| declare -a env_args=() | |
| for node in $(yq -r '.spec.components | keys | .[]' "$bp"); do | |
| slug=$(yq -r ".spec.components.\"$node\".componentId" "$bp" \ | |
| | sed -n 's/^\${\(.*\)_COMPONENT_ID}$/\1/p' | tr '[:upper:]_' '[:lower:]-') | |
| f=$(grep -l "slug: $slug\$" "$example"/musher/component*.yaml || true) | |
| [ -n "$f" ] || continue | |
| ref=$(yq -r '.spec.workload.source.ref // ""' "$f") | |
| case "$ref" in '$'*) continue ;; esac | |
| docker run --rm -d --network "$net" --name "$node" "$ref" >/dev/null | |
| echo "sidecar $node ← $ref" | |
| done | |
| for input in $(yq -r '.spec.contract.inputs // {} | keys | .[]' "$spec"); do | |
| [ "$(yq -r ".spec.contract.inputs.\"$input\".suppliedBy // \"USER\"" "$spec")" = CONNECTION ] || continue | |
| probed_node=$(yq -r ".spec.components | to_entries | map(select(.value.connections | has(\"$input\"))) | .[0].key" "$bp") | |
| from_role=$(yq -r ".spec.components.\"$probed_node\".connections.\"$input\".fromRole" "$bp") | |
| from_output=$(yq -r ".spec.components.\"$probed_node\".connections.\"$input\".fromOutput" "$bp") | |
| key=$(yq -r ".spec.contract.inputs.\"$input\".target.envVarKey" "$spec") | |
| prod_slug=$(yq -r ".spec.components.\"$from_role\".componentId" "$bp" \ | |
| | sed -n 's/^\${\(.*\)_COMPONENT_ID}$/\1/p' | tr '[:upper:]_' '[:lower:]-') | |
| prod=$(grep -l "slug: $prod_slug\$" "$example"/musher/component*.yaml) | |
| tmpl=$(yq -r ".spec.contract.outputs.\"$from_output\".value" "$prod") | |
| ep=$(sed -n 's/.*{{ self\.privateAddress\.\([a-z0-9-]*\) }}.*/\1/p' <<<"$tmpl") | |
| prod_port=$(yq -r ".spec.workload.endpoints.\"$ep\".containerPort" "$prod") | |
| value=$(sed "s|{{ self\.privateAddress\.$ep }}|$from_role:$prod_port|" <<<"$tmpl") | |
| env_args+=(--env "$key=$value") | |
| echo "wired $key=$value" | |
| done | |
| docker run --rm -d --network "$net" --name "ci-$example" \ | |
| "${env_args[@]}" -p "18080:$port" "musher-examples/$example:ci" | |
| for _ in $(seq 1 30); do | |
| curl -fsS "http://localhost:18080$probe" >/dev/null 2>&1 && break | |
| sleep 1 | |
| done | |
| curl -fsS "http://localhost:18080$probe" | |
| docker rm -f "ci-$example" | |
| docker network rm "$net" >/dev/null | |
| specs: | |
| name: Validate specs | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Every spec parses and carries the envelope | |
| run: | | |
| set -euo pipefail | |
| status=0 | |
| for f in */musher/*.yaml; do | |
| if ! yq -e '.specVersion == "v1"' "$f" >/dev/null 2>&1; then | |
| echo "::error file=$f::missing or unsupported specVersion"; status=1; continue | |
| fi | |
| if ! yq -e '.kind == "COMPONENT" or .kind == "BLUEPRINT"' "$f" >/dev/null 2>&1; then | |
| echo "::error file=$f::kind must be COMPONENT or BLUEPRINT"; status=1; continue | |
| fi | |
| yq -e 'has("metadata") and has("spec")' "$f" >/dev/null 2>&1 \ | |
| || { echo "::error file=$f::missing metadata or spec"; status=1; } | |
| echo "ok $f" | |
| done | |
| exit $status | |
| - name: No floating image tags | |
| # A floating tag builds fine and is rejected at publish. Catch it here, | |
| # where the error names the file. | |
| run: | | |
| set -euo pipefail | |
| status=0 | |
| for f in */musher/component*.yaml; do | |
| ref=$(yq -r '.spec.workload.source.ref // ""' "$f") | |
| case "$ref" in | |
| # A ref starting with '$' is a placeholder the apply script | |
| # substitutes at deploy time; there is no tag to judge yet. | |
| '$'*|'') continue ;; | |
| *:latest|*:main|*:master|*:stable|*:edge|*:nightly|*:dev|*:rolling) | |
| echo "::error file=$f::floating tag '$ref' is rejected at publish"; status=1 ;; | |
| *:*) ;; | |
| *) echo "::error file=$f::image ref '$ref' has no tag"; status=1 ;; | |
| esac | |
| done | |
| exit $status | |
| - name: Every blueprint node resolves to a component file | |
| run: | | |
| set -euo pipefail | |
| status=0 | |
| for bp in */musher/blueprint.yaml; do | |
| dir=$(dirname "$bp") | |
| slugs=$(yq -r '.metadata.slug' "$dir"/component*.yaml) | |
| for node in $(yq -r '.spec.components | keys | .[]' "$bp"); do | |
| ref=$(yq -r ".spec.components.\"$node\".componentId" "$bp") | |
| # Placeholders are ${SLUG_COMPONENT_ID}; check the slug exists. | |
| slug=$(echo "$ref" | sed -n 's/^\${\(.*\)_COMPONENT_ID}$/\1/p' \ | |
| | tr '[:upper:]_' '[:lower:]-') | |
| if [ -n "$slug" ] && ! grep -qx "$slug" <<<"$slugs"; then | |
| echo "::error file=$bp::node '$node' references unknown component '$slug'" | |
| status=1 | |
| fi | |
| done | |
| done | |
| exit $status | |
| shell: | |
| name: Shellcheck | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - run: shellcheck scripts/*.sh | |
| required: | |
| name: CI / required | |
| if: always() | |
| needs: [build, specs, shell] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Fail if any job did not succeed | |
| run: | | |
| results='${{ join(needs.*.result, ' ') }}' | |
| echo "job results: $results" | |
| for r in $results; do | |
| [ "$r" = "success" ] || { echo "a required job reported '$r'"; exit 1; } | |
| done |