Repository navigation
ci: CON-1576 release each template family on its own version (#170) #186
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: 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 |