Search code examples
github-actions

Ensure job order when rebuilding devcontainer, but do not require rebuilding devcontainer


I have a multi-job workflow where a devcontainer is rebuilt when its Dockerfile changes, and various sub-projects are built and tested if changes are made to them. If changes are made to the devcontainer, I'd like for the devcontainer to finish building before running subsequent jobs. But if no changes are made to the devcontainer, they should still build depending on their rules.

I use needs: [ changes, rebuild-devcontainer ] for the foo and bar jobs.

The changes job always run and exports variables that are used in a build conditional:

if: needs.changes.outputs.foo == 'true'

The part that doesn't work is adding rebuild-devcontainer to the needs::

  • It ensures order when the devcontainer does need rebuilding
  • But it skips building foo and bar entirely when the devcontainer isn't touched

How can I ensure order when the devcontainer is rebuilt, but not require rebuilding?

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      devcontainer: ${{ steps.filter.outputs.devcontainer }}
      foo: ${{ steps.filter.outputs.foo }}
      bar: ${{ steps.filter.outputs.bar }}
    steps:
      - uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2.11.1, 2022-10-12
        id: filter
        with:
          filters: |
            devcontainer:
              - '.devcontainer/Dockerfile'
            foo:
              - 'foo/**'
            bar:
              - 'bar/**'

  rebuild-devcontainer:
    name: Rebuild devcontainer
    runs-on: ubuntu-latest
    needs: changes
    if: needs.changes.outputs.devcontainer == 'true'
    steps:
      - name: Checkout sources
        uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2, 2023-04-13

      - name: Login to GHCR
        uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0, 2023-09-12
        with:
          registry: ghcr.io
          username: ${{ secrets.GHCR_USER }}
          password: ${{ secrets.GHCR_TOKEN }}

      - name: Build and upload devcontainer to GHCR
        uses: devcontainers/ci@57eaf0c9b518a76872bc429cdceefd65a912309b # v0.3.1900000329, 2023-04-17
        with:
          imageName: ghcr.io/${{ secrets.GHCR_USER }}/our-devcontainer
          push: always
          runCmd: |
            # Cache build artifacts in devcontainer
            cargo test --workspace --no-run

  build-foo:
    name: Build foo
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/${{ secrets.GHCR_USER }}/our-devcontainer
      credentials:
        username: ${{ secrets.GHCR_USER }}
        password: ${{ secrets.GHCR_TOKEN }}
    needs: [ changes, rebuild-devcontainer ]
    if: needs.changes.outputs.foo == 'true'
    steps:
      - name: Checkout sources
        uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2, 2023-04-13

      - name: Login to GHCR
        uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0, 2023-09-12
        with:
          registry: ghcr.io
          username: ${{ secrets.GHCR_USER }}
          password: ${{ secrets.GHCR_TOKEN }}

      - name: Build foo in devcontainer
        working-directory: foo
        run: make foo

  build-bar:
    ...

Solution

  • The solution is to add always() as a condition on the jobs that have needs: rebuild-devcontainer:

    Causes the step to always execute, and returns true, even when canceled. The always expression is best used at the step level or on tasks that you expect to run even when a job is canceled. For example, you can use always to send logs even when a job is canceled.

    Warning: Avoid using always for any task that could suffer from a critical failure, for example: getting sources, otherwise the workflow may hang until it times out. If you want to run a job or step regardless of its success or failure, use the recommended alternative: if: ${{ !cancelled() }}

    The interpretation:

    • Adding needs: [ ..., rebuild-devcontainer ] makes the order right, but does not execute when rebuild-devcontainer does not execute.
    • Additionally adding if: always() or if always() && ... executes the step regardless of whether rebuild-devcontainer executes. This changes the meaning of needs to "build after".
    • Alternatively, !cancelled() or !failure() might be more appropriate.
    jobs:
      ...
    
      build-foo:
        name: Build foo
        runs-on: ubuntu-latest
        container:
          image: ghcr.io/${{ secrets.GHCR_USER }}/our-devcontainer
          credentials:
            username: ${{ secrets.GHCR_USER }}
            password: ${{ secrets.GHCR_TOKEN }}
        needs: [ changes, rebuild-devcontainer ]
        # This job already has another `if:` condition, so `always() && ...`:
        if: always() && needs.changes.outputs.foo == 'true'
        ...
    
      build-bar:
        name: Build bar
        runs-on: ubuntu-latest
        container:
          image: ghcr.io/${{ secrets.GHCR_USER }}/our-devcontainer
          credentials:
            username: ${{ secrets.GHCR_USER }}
            password: ${{ secrets.GHCR_TOKEN }}
        needs: [ changes, rebuild-devcontainer ]
        # This job is otherwise unconditioned, so `always()`:
        if: always()
        ...