Skip to content

Custom build workflows

Sometimes piot's default build-matrix logic doesn't cover a package you own — a bespoke 5-platform wheel build with a pre-compiled Rust CLI staged into the Python source tree, a non-standard napi layout, a build that depends on a specific toolchain image. For these cases, piot's [[package]].build_workflow field lets you hand the build step off to a workflow you wrote, while keeping the rest of the piot-driven flow (plan, cascade, idempotency, publish, tag, GitHub Release) intact.

This page is the composition pattern. If you're not sure you need it, you probably don't — piot's default build = "napi" / "bundled-cli" / "maturin" cover most shapes. Use this knob when none of them fit.

Declare the delegation

toml
[[package]]
name = "my-py"
kind = "pypi"
path = "packages/python"
paths = ["packages/python/**"]
build = "maturin"
targets = [
  "x86_64-unknown-linux-gnu",
  "aarch64-unknown-linux-gnu",
  "x86_64-apple-darwin",
  "aarch64-apple-darwin",
  "x86_64-pc-windows-msvc",
]
build_workflow = "publish-python.yml"
  • build_workflow is a bare filename, not a path. The workflow must live under .github/workflows/.
  • The field works on any kind (crates, pypi, npm).
  • When set, piot's plan stamps build_workflow on every matrix row for that package. Your release.yml reads that field to decide whether to run piot's default build steps or delegate.

Wire the composition in release.yml

GitHub Actions doesn't support dynamic uses: — a workflow reference must be a literal string, not a matrix variable. That means piot can't auto-wire the dispatch for you. The pattern is:

  1. Your release.yml has piot's default build job for rows where matrix.build_workflow == ''.
  2. A second build job, gated on a specific package, runs uses: ./.github/workflows/<file> statically for that package.
yaml
jobs:
  plan:
    # scaffolded by piot
    outputs:
      matrix:                  ${{ steps.plan.outputs.matrix }}
      has_custom_python_build: ${{ steps.decide.outputs.has_custom }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - id: plan
        uses: thekevinscott/putitoutthere@v0
        with:
          command: plan
      # Split the matrix so the default build job only sees the rows
      # that don't delegate.
      - id: decide
        run: |
          m='${{ steps.plan.outputs.matrix }}'
          echo "has_custom=$(echo "$m" | jq 'any(.[]; .build_workflow == "publish-python.yml")')" >> $GITHUB_OUTPUT

  # Default piot-driven build, skipping delegated rows.
  build:
    needs: plan
    if: fromJSON(needs.plan.outputs.matrix || '[]')[0] != null
    strategy:
      fail-fast: false
      matrix:
        include: ${{ fromJSON(needs.plan.outputs.matrix) }}
    runs-on: ${{ matrix.runs_on }}
    steps:
      - if: matrix.build_workflow == ''
        # … piot's scaffolded steps (setup-rust / setup-python / etc.) …
        run: echo "default build for ${{ matrix.name }}-${{ matrix.target }}"

  # Delegated build for my-py via a consumer-owned workflow_call.
  build-my-py:
    needs: plan
    if: needs.plan.outputs.has_custom_python_build == 'true'
    uses: ./.github/workflows/publish-python.yml
    with:
      version: ${{ fromJSON(needs.plan.outputs.matrix)[0].version }}
    secrets: inherit

  publish:
    needs: [plan, build, build-my-py]
    if: always() && !failure() && !cancelled()
    # … piot's scaffolded publish job, unchanged …

The specifics of how to pick the version + targets out of the matrix are yours — the matrix JSON is an array of rows, and the delegated workflow usually just needs the planned version + the list of target triples.

What the delegated workflow should do

A consumer-owned publish-python.yml receives version (and whatever else you wire) as an input and uploads build artifacts under the artifact_name convention piot's publish job expects:

  • ${pkg.name}-wheel-${target} for per-target wheels (pypi maturin).
  • ${pkg.name}-sdist for the sdist.
  • ${pkg.name}-crate for the cargo package tarball.
  • ${pkg.name}-${target} / ${pkg.name}-main for npm platform packages.

Piot's publish job calls actions/download-artifact@v4 with the default path: artifacts, so the artifacts land under artifacts/<artifact_name>/… on the publish runner. As long as your delegated workflow uploads under the right name, piot's handler picks the files up and publishes them normally.

Inputs your delegated workflow should take:

yaml
on:
  workflow_call:
    inputs:
      version:
        description: The planned version piot computed.
        type: string
        required: true

Targets, paths, and artifact names can be fixed in your delegated workflow since it's already specific to a single package.

Idempotency, plan, tag, and release stay piot's

build_workflow delegates only the build step. Everything else still runs the piot way:

  • plan decides whether the package cascades (based on the package's paths globs + the release: trailer).
  • Pre-publish auth + artifact completeness checks run against the artifacts your delegated workflow uploaded.
  • handler.publish calls the registry's publish API (twine, cargo publish, npm publish) using the artifacts on disk.
  • The git tag + GitHub Release happen after a successful publish.

No part of piot's publish contract changes based on whether a package used build_workflow. The feature is scoped to replacing the build step, not the whole pipeline.

When to use this vs. forking

Use build_workflow when:

  • You have a bespoke build matrix that piot's declarative build modes don't express (pre-compiled binaries staged into a source tree, container-image builds, custom toolchain images).
  • You want to keep the bespoke workflow as its own file so upstream piot updates don't touch it.
  • You're okay hand-wiring the uses: dispatch job in release.yml.

Don't use it when:

  • build = "napi" or build = "bundled-cli" already covers your shape — piot's platform-package synth is opinionated and battle- tested on those paths.
  • Your "bespoke" step is really just a few extra shell commands — add them to the scaffolded build job as additional steps instead.
  • You need piot to also delegate the publish — that's a separate feature under discussion; the plain build_workflow only hands off the build half.

Released under the MIT License.