Skip to content

ci: CON-1576 release each template family on its own version (#170) #186

ci: CON-1576 release each template family on its own version (#170)

ci: CON-1576 release each template family on its own version (#170) #186

Workflow file for this run

name: Build and Release
# Single orchestrator for image builds + release. It computes the version ONCE
# and passes it to the reusable per-family build workflows (base.yml, nvidia.yml,
# rocm.yml), waits for all of them, and only then — on a releasable push to
# main — tags vX.Y.Z and publishes a GitHub Release.
#
# This replaces the previous decoupled model where every workflow recomputed the
# version independently and release.yml tagged without waiting for the builds
# (so a release could be cut before/despite a failed or missing image family).
on:
# Directory globs (not individual files) so no build-relevant input — e.g.
# requirements.txt, pre_start.sh, start.sh — is missed. Pipeline-only (ci:)
# changes to the workflow/action files intentionally do NOT trigger a build.
pull_request:
paths:
- "official-templates/**"
- "container-template/**"
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
id-token: write
pull-requests: read
# PR: cancel superseded runs (per ref). Push to main: SERIALIZE — a constant
# group with cancel-in-progress:false makes a 2nd merge wait for the 1st to
# finish (and create its tag), so two merges landing close together can't both
# compute the same next version and race.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'push' && 'main-release' || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Which families this event touches, plus everything that builds FROM them.
# One mechanism for PRs, pushes and manual runs: the family graph.
plan:
runs-on: ubuntu-latest
outputs:
families: ${{ steps.p.outputs.families }}
workflows: ${{ steps.p.outputs.workflows }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
# Full history and tags: the plan diffs from each family's last
# release tag.
fetch-depth: 0
fetch-tags: true
- name: Plan families
id: p
run: |
set -euo pipefail
plan() { python3 .github/scripts/plan_families.py "$@"; }
case "${GITHUB_EVENT_NAME}" in
pull_request)
# What this PR would change, against the branch it targets.
git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD" \
> "${RUNNER_TEMP}/changed.txt"
MODE=(--changed-files "${RUNNER_TEMP}/changed.txt")
;;
push)
# Not the push diff: a queued run is dropped when a third push
# arrives, and its changes would then never be built. Each
# family is compared against its own last release tag instead,
# so anything unreleased is picked up by the next push.
MODE=(--since-release)
;;
*)
# workflow_dispatch has no diff to read.
MODE=(--all)
;;
esac
FAMILIES="$(plan "${MODE[@]}")"
WORKFLOWS="$(plan "${MODE[@]}" --workflows)"
echo "families=${FAMILIES}" >> "$GITHUB_OUTPUT"
echo "workflows=${WORKFLOWS}" >> "$GITHUB_OUTPUT"
# Each build workflow is called only when the plan needs a family it covers,
# and decides internally which of them to build.
base:
needs: plan
if: >-
!cancelled()
&& contains(fromJSON(needs.plan.outputs.workflows), 'base')
uses: ./.github/workflows/base.yml
secrets: inherit
with:
families: ${{ needs.plan.outputs.families }}
nvidia:
needs: plan
if: >-
!cancelled()
&& contains(fromJSON(needs.plan.outputs.workflows), 'nvidia')
uses: ./.github/workflows/nvidia.yml
secrets: inherit
with:
families: ${{ needs.plan.outputs.families }}
rocm:
needs: plan
if: >-
!cancelled()
&& contains(fromJSON(needs.plan.outputs.workflows), 'rocm')
uses: ./.github/workflows/rocm.yml
secrets: inherit
with:
families: ${{ needs.plan.outputs.families }}
comfyui:
needs: plan
if: >-
!cancelled()
&& contains(fromJSON(needs.plan.outputs.workflows), 'comfyui')
uses: ./.github/workflows/comfyui.yml
secrets: inherit
with:
families: ${{ needs.plan.outputs.families }}
run-functional-tests: true
# One tag + GitHub Release per changed family, on a releasable push to main.
# Each family waits only on its own build: a scan that fails on one image
# must not keep the others unreleased while their images are already pushed.
release:
needs: [plan, base, nvidia, rocm, comfyui]
if: >-
!cancelled()
&& github.event_name == 'push'
&& needs.plan.result == 'success'
&& needs.plan.outputs.families != '[]'
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
# Independent tags, so one family failing must not hide the others.
fail-fast: false
matrix:
family: ${{ fromJSON(needs.plan.outputs.families) }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
fetch-depth: 0
fetch-tags: true
- name: Compute version
id: v
uses: ./.github/actions/compute-version
with:
family: ${{ matrix.family }}
# The job-level `if` can't see `matrix`, so the per-family gate lives
# here: release this family only if the workflow that builds it passed.
- name: Check this family's build
id: gate
env:
RESULTS: ${{ toJSON(needs) }}
FAMILY: ${{ matrix.family }}
RELEASABLE: ${{ steps.v.outputs.should-release }}
run: |
set -euo pipefail
WORKFLOW="$(python3 .github/scripts/plan_families.py --workflow "${FAMILY}")"
RESULT="$(printf '%s' "${RESULTS}" | jq -r --arg w "${WORKFLOW}" '.[$w].result')"
echo "${FAMILY} is built by '${WORKFLOW}': ${RESULT}"
if [ "${RELEASABLE}" != "true" ]; then
echo "release=false" >> "$GITHUB_OUTPUT"
echo "${FAMILY} has nothing releasable outstanding"
elif [ "${RESULT}" = "success" ]; then
echo "release=true" >> "$GITHUB_OUTPUT"
else
echo "release=false" >> "$GITHUB_OUTPUT"
echo "::warning::${FAMILY} not released — its build ${RESULT}"
fi
# Notes are scoped to the family: a release can carry a change from an
# earlier commit (a run that failed, or one dropped from the queue), so
# the latest squash description alone would describe the wrong thing.
# GitHub's own generation is not used either — it compares against the
# newest release in the repo, which belongs to some other family.
- name: Release body
id: notes
if: steps.gate.outputs.release == 'true'
env:
FAMILY: ${{ matrix.family }}
TAG: ${{ steps.v.outputs.tag }}
PREV_VERSION: ${{ steps.v.outputs.previous-version }}
run: |
set -euo pipefail
BODY="${RUNNER_TEMP}/release-body-${FAMILY}.md"
PATHS=()
while IFS= read -r spec; do
PATHS+=("${spec}")
done < <(python3 .github/scripts/plan_families.py --paths "${FAMILY}")
python3 .github/scripts/release_notes.py \
"${FAMILY}" "${PREV_VERSION}" "${TAG}" "${PATHS[@]}" > "${BODY}"
echo "::group::release body for ${FAMILY}"
cat "${BODY}"
echo "::endgroup::"
echo "path=${BODY}" >> "$GITHUB_OUTPUT"
- name: Create tag and GitHub Release
if: steps.gate.outputs.release == 'true'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228
with:
tag_name: ${{ steps.v.outputs.tag }}
name: ${{ steps.v.outputs.tag }}
target_commitish: ${{ github.sha }}
body_path: ${{ steps.notes.outputs.path }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
if: steps.gate.outputs.release == 'true'
run: |
echo "Released ${{ steps.v.outputs.tag }} (bump ${{ steps.v.outputs.bump }})"
echo "Previous: ${{ steps.v.outputs.previous-version || 'none — first release' }}"
# After a real release, sweep aged-out RC/dev tags off Docker Hub. Runs the
# shared cleanup workflow (not dry-run: workflow_call defaults dry_run=false),
# so it actually deletes. Age-based retention keeps open PRs' recent RC images.
cleanup:
needs: [plan, release]
# Keyed on the release actually succeeding, not just on the plan being
# non-empty — with !cancelled() a failed release would otherwise still
# sweep tags.
if: >-
!cancelled()
&& needs.release.result == 'success'
uses: ./.github/workflows/cleanup.yml
secrets: inherit