August Feng

Keeping a linear history with GitHub Actions

workflows triggered by the pull_request event uses merged code

GitHub Actions surprised me with an unexpected behavior when it runs jobs triggered from a pull request.

Let's assume the following git history:

gitGraph commit id: "foo" branch feature commit id: "baz" checkout main commit id: "bar"

I originally assumed that a pull request would checkout the branch feature for the pipeline steps.

However, what actually happens is that the workflow's github context uses a ref which already has feature branch merged into main, and this is what will be checked used by the checkout action by default:

gitGraph commit id: "foo" branch feature commit id: "baz" checkout main commit id: "bar" branch pull/1/merge merge feature id: "merge 'baz' into 'bar'" type: HIGHLIGHT

Here are a few traces recovered from a job to show this:

  • dumping the ${{ github.context }} confirms that ref is not the branch from the pull request:

      {
          ...
          "ref": "refs/pull/1/merge",
          ...
      }
  • a graph of the commit history (git log --all --decorate --oneline --graph) confirms the diagram above:

      *   55af0f6 (HEAD, pull/1/merge) Merge ecd17b69ece19b75fa1fbafe30add8e951ab2e2f into a96f9e46bb1f59db25c108ae20f7064dc4dca685
      |\  
      | * ecd17b6 (origin/feature) baz
      * | a96f9e4 (origin/main) bar
      |/  
      * 18a4b2e foo

keeping pull requests up-to-date

As we understand now, GitHub's workflows run the checks as if the code had been merged. This can get outdated fast if someone else's pull request is merged before ours, and potentially disastrous under certain circumstances.

disaster case study

Let's imagine this scenario where a pull request for feature-1 and feature-2 have been opened, and feature-2 is merged into upstream.

The act of merging feature-2 will invalidate the pipeline run #1, but the check remains green.

gitGraph commit id: "foo" branch feature-1 commit id: "bar" checkout main branch pull/1/merge merge feature-1 id: "pipeline run #1" checkout main branch feature-2 commit id: "baz" checkout main branch pull/2/merge merge feature-2 id: "pipeline run #2" checkout main merge feature-2 id: "merge feature-2 into main"

At revision foo, there is simple configuration file (config.json):

  {
      "foobar": "helloworld"
  }

Now assume branch feature-1 introduces a script (tested in the pipeline) which reads this:

  jq '.foobar' config.json

Finally, imagine that the code change introduced by feature-2 renames the foobar key to foobaz.

It would be foolish to continue trusting the green check mark from pipeline run #1!

requiring branches to be up-to-date before merging

GitHub is able to require that status checks (workflows) pass in order to merge a branch. While using this feature, we can also require that branches be up-to-date before merging.

Enabling this feature will surround the merge button with a red warning if a check fails and hinting that the base branch needs to be updated if it is outdated. If branch protection bypassing is disabled, then the button will simply be greyed out.

Note that this feature toggle requires at least one status check to be required, which sensibly should be the build step of the pipeline.

Linear History

Finally, if we only merge pull requests that have run tests with up-to-date branches, then we might as well require linear history in the commits!

Otherwise we would have commits that are older than others, but could have been tested later. Let's use our previous example to show this.

Although the commit bar from feature-1 will show as older than baz from feature-2, it has actually been tested (pipeline run #3) against code which has already introduced baz.

gitGraph commit id: "foo" branch feature-1 commit id: "bar" checkout main branch pull/1/merge merge feature-1 id: "pipeline run #1" checkout main branch feature-2 commit id: "baz" checkout main branch pull/2/merge merge feature-2 id: "pipeline run #2" checkout main merge feature-2 id: "merge feature-2 into main" checkout feature-1 merge main checkout pull/1/merge merge feature-1 id: "pipeline run #3" checkout main merge feature-1 id: "merge feature-1 into main"

This would have been the git history had we updated our branch with a rebase, and then merged with a rebase and merge strategy.

gitGraph commit id: "foo" checkout main branch feature-2 commit id: "baz" checkout main branch pull/2/merge merge feature-2 id: "pipeline run #2" checkout main commit id: "baz'" branch feature-1 commit id: "bar" checkout main branch pull/1/merge merge feature-1 id: "pipeline run #3" checkout main commit id: "bar'"

Much easier to follow!

Conclusion

So by leveraging GitHub Actions' prepared refs and requiring a linear history, we are sure to not be surprised as code lands in mainline all the while reflecting the testing into the commit history!