December 15, 2022
10
min read

GitHub Actions: Two-stage Workflow

A two-stage workflow allows open-source projects to safely preview pull requests from outside contributors.

Note: A two-stage workflow is only recommended for open-source/public repositories since they may receive pull requests from outside contributors. For private repositories that do not expect outside contributions, a single workflow is secure and sufficient.

Why does Uffizzi use two workflows instead of one?

TL;DR

Using just one workflow would mean that every outside contributor to your project would need a Uffizzi account before they could preview their pull request, or else maintainers would need to share credentials with contributors. A two-stage workflow solves this problem by first building the application in the context of the contributor's head branch, then delegating responsibility to the target base branch in the second stage to authenticate with Uffizzi.

Introduction

Most open-source projects follow a fork and pull model because it allows new contributors to easily get started without upfront coordination with the project maintainers. This design, however, presents some challenges when using credentials in your GitHub Actions workflows—specifically for OIDC tokens and any encrypted secrets stored in GitHub. To handle these situations, Uffizzi has developed a two-stage workflow: one stage that builds the application in the context of the contributor's head branch and a second stage that authenticates with Uffizzi from the target base branch. By separating workflows into unprivileged and privileged stages, this design preserves the fork and pull model since contributors do not need any credentials to initiate previews. Meanwhile workflows scoped to the default branch of the base repository are the only ones able to authenticate with Uffizzi. The remainder of this article explains the two-stage workflow in detail.

How workflows authenticate with Uffizzi via OIDC

Using the official preview action, workflows authenticate with Uffizzi Cloud via OpenID Connect (OIDC) JSON Web Tokens (JWT) provided by GitHub. Every time a job runs, GitHub's OIDC Provider automatically generates an OIDC token, which is signed by GitHub to verify the workflow runner's identity. When this token is passed to the preview action, Uffizzi verifies the signature on the token to confirm that the request came from GitHub and the identity of the requester (i.e., the GitHub username). No other credentials are needed by Uffizzi to authenticate a request. This point is worth emphasizing: you do not need a password to authenticate with Uffizzi. In fact, when the preview workflow first runs, Uffizzi will automatically create an account from the metadata of the OIDC JWT, so it's not even necessary to first create an account at uffizzi.com before seeing your previews.

So why not create Uffizzi accounts automatically for outside contributors using OIDC tokens from their workflows ? The problem has to do with how GitHub limits permissions for public forks...

Problem

The problem is that a workflow run from a public fork cannot obtain the required permissions to request the OIDC token. More specifically, to obtain an OIDC token, the job or workflow run requires a `permissions` setting with `id-token: write` because it needs to generate a new token for this run; however, the maximum allowable permission for a forked repository is `read`. In other words, a contributor's fork requesting write access to the OIDC token will fail because it is only allowed read access. For this reason, the target base branch must initiate the workflow run that calls the preview action.

So how do we solve this problem? Enter the two-stage workflow...

Solution

To solve this problem, we outline a two-stage workflow that separates responsibilities into build and preview stages. In practice, these workflows are configured as separate YAML files typically called `uffizzi-build.yaml` and `uffizzi-preview.yaml`, respectively. The first stage runs in the context of the contributor's head branch to build the application, and the second stage runs in the context of the target base branch to request the OIDC token and authenticate with Uffizzi. The result is that a maintainer can safely allow outside contributors to preview pull requests to a base repository without those contributors needing OIDC tokens to authenticate with Uffizzi.

Stage 1: uffizzi-build.yaml

A good example of a `uffizzi-build.yaml` workflow can be seen here on the Livebook project.

The first stage is triggered by a `pull_request` event. It is defined within the context of the pull request head branch and has read-only privileges by default to the base repository (although this workflow does not need or use these read privileges). As is typical for most workflows triggered by pull requests, it does two things: builds container images and pushes images to a container registry.

The workflow builds container images from source for each pull request to ensure that the latest changes are included in the preview. As for pushing images, Uffizzi provides an anonymous and ephemeral container registry at registry.uffizzi.com. Since the registry is anonymous, contributors' workflow runs do not need to authenticate to push images. Again this design decision is intended to reduce friction for both maintainer and contributor and to preserve the fork and pull model.

Additionally, this workflow will render a Docker Compose file that is cached for later use by the second stage workflow.

Stage 2: uffizzi-preview.yaml

A good example of a `uffizzi-preview.yaml` workflow can be seen again here on the Livebook project.

The second stage is defined within the default branch of the base repository and is triggered by the `workflow_run` event, if and only if the first stage successfully completes. This workflow has write privileges to the base repository. In this workflow, the cached Compose file is downloaded, unzipped, and then passed as a parameter to the deploy preview job, which itself calls the Uffizzi reusable workflow. It's in this preview job that the `id-token: write` permission is set, allowing this workflow to generate the OIDC token that gets sent to Uffizzi.

Note: The uffizzi-preview.yaml workflow will not run until it is merged into the default branch of the base repository. This means the first time the Uffizzi workflow files are merged, you will not see a preview created. However, once the workflow files are in the default branch, you will see previews on subsequent pull requests.

Other considerations

Some users have asked our team why we do not use the `pull_request_target` event instead of the two-stage workflow. The reason is because `pull_request_target` can allow malicious actors that open pull requests to obtain repository write permissions or to steal repository secrets. You can read more about this attack vector in the GitHub Security Lab blog.