Deployer on GitHub Actions

• 23 min read

Today I would like to share with you my approach on how I deploy my PHP applications with deployer through GitHub Actions. This might sound counterintuitive, as deploying with deployer is already easy: Running a CLI command and you're done.

However, running deployer on GitHub Actions allows you to make the deployment process available to more people and/or to more situations. For example you could trigger the deployment automatically whenever a new commit is made to a particular branch, when a new release has been tagged, a new Pull Request is opened or when a user runs a Slack Command in a certain channel.

This article will go into detail how to write the GitHub Actions workflows and gives you examples on how to trigger these workflows. I will not cover how to configure deployer itself.
Accompanying this article is an example Laravel application with example workflows for different deployment scenarios. If you like to read code first you can have a look at the repository on GitHub (I will reference this example project throughout the article).

Before we dive deep into the workflow examples, I would like to give you a short introduction to what deployer and GitHub Actions are.

You can skip the intros and jump right to the requirements and the first example. There is a FAQ section at the end to answer common questions that might come up.

What is deployer?

deployer is a deployment tool written in PHP. It comes with "Zero Downtime Deployments" out of the box and can be extended by writing simple PHP code. (capistrano would be the equivalent in the Ruby world).

But what does "Zero Downtime Deployment" mean? Here is how I would describe it:

When your app is being deployed, deployer creates a fresh copy of your app in a folder, prepares the artefacts that are needed to run your app (compiles your CSS and JavaScript files, installs your Composer dependencies) and then symlinks the newly created folder to be picked up by the webserver.

Using this approach your app is still accessible and useable, while deployer installs your dependencies and builds your frontend assets in the newly created folder.
In contrast, if you install composer dependencies without zero downtime deployments, your app might break for for a couple of seconds an your visitors will see error messages. Not nice.

Another benefit of deployer is that each deployment is atomic: If the deployed code breaks your app you can rollback to a previous deployment by running dep rollback in your terminal.

If you want to learn more about deployer, I can recommend Loris Leiva's article series Deploy your Laravel App from Scratch. Reading the series I've learned to better organize my deployer-file though I've been using deployer for years now.

The main focus of this article however is how to trigger a deployment through GitHub Actions. To keep things simple we will be using a basic deployer-file to deploy our demo application. You can find the GitHub repository here and the used deployer file here.

Next a short introduction to GitHub Actions.

What is GitHub Actions?

GitHub Actions is the continous integration and delivery feature baked into GitHub. With Actions you can run any type of software in reaction to an event that happens in GitHub. An event can be a "git-push", when a pull request is opened, a new issue is created and much more.

Workflows are at the core of GitHub Actions and they are written in YAML. The most common workflows for programming projects are running tests, running code linters or optimizing image sizes.
On my site I've covered some of these common workflows (see "Run Laravel test suite on GitHub Actions with laravel-docker" or "Run prettier or php-cs-fixer with GitHub Actions") and special ones – like Auto Merging Dependabot Pull Requests.

Why deploy your app through GitHub Actions?

As mentioned in the beginning, running deployer on Actions can give more people in your team or your organisation the power to deploy your applications. In simple terms, by moving to GitHub Actions, the execution of the deployer command is moved from a local terminal to an environment which is accessible through an API.

If your team works with Slack, you might write a Slack integration to /deploy app prod from a channel. Or write an Alfred workflow to deploy your side project without starting your terminal. Or write an iOS Shortcut to deploy your app from the go.

And than there is the enormous list of events that can trigger a workflow in GitHub Actions. The schedule-trigger allows you to create a nightly deployment of your app. Or listening to the release event allows you deploy your app when a new release has been tagged. Perfect for when your team is working in sprints.

I think you get the idea. Moving the deployment process away from your terminal to GitHub itself gives you endless posibilites to trigger a deployment from anywhere.

When to trigger the deployment?

Before we go any further, we need to answer an important question: When should the deployment for an environment for your application be triggered?

Should the default branch always be deployed to your staging environment when a new commit is pushed? Should the production environment be deployed when a new release is tagged? Is each deployment triggered manually?

The answer to this question is different from organisation to organisation. For example in my team we deploy manually – meaning we invoked dep deploy manually from our terminals when we wanted (4-5 times a day). But that's what works for us.
Your team might work in sprints and deploy a new version every other week.

As hinted in the previous section, GitHub gives us a vast list of events through which a deployment can be triggered. To keep things simple, I've decided to cover the following deploy scenarios in this article:

  • Deploying manually through the GitHub UI and through the GitHub API.
  • Deploy an app to production when a new version is tagged and released.
  • Deploy an app to staging when a new commit is pushed to GitHub.
  • Deploy a nightly build to a test environment.

Those example will cover the basics and are a good starting point to built upon.

Now – finally – let's dive into the workflows and code.

The Deployer GitHub Action

All example workflows will use the deployphp/action GitHub Action. The Action is maintained by the same people who make deployer.

But before you scroll down and copy and paste the workflows, we need to make preparations. First you need a SSH key, which you need to pass to the deployphp/action. Otherwhise deployer on GitHub Actions will not be able to connect to your server.

If you have an SSH key: great! If not, follow this tutorial on how to create a new SSH key. Make sure to update your server so that we can authenticate and connect to the server using the generated SSH key. You can learn more about how to do this in this DigitalOcean tutorial.

Later, we will use a PRIVATE_KEY secret in our deployment workflows. This secret holds the SSH private key which will connect GitHub Actions to your server.
To add the secret go to your repository or organisation settings and click on "Secrets" in the sidebar.
Click on "New repository secret", give it the name PRIVATE_KEY and store the SSH private key as its value (The part with "-----BEGIN RSA PRIVATE KEY-----").

Next, we need the KNOWN_HOSTS value. This will later prevent GitHub Actions from asking you, if you want to connect to your own server. (Without the known_hosts value, the workflow will timeout, as you can't manually interact with the workflow run.
To get this value, execute the following command from your local CLI.

ssh-keyscan <name_of_your_host (eg. example.com)>

The output might look like this:

<name_of_your_host> ssh-rsa AAAABBBCCCDD...

The name of the server, where your app should be deployed, followed by the public key value. Copy the line to your clipboard which corresponds to the private SSH key you used for PRIVATE_KEY.

Now add a KNOWN_HOSTS secret to your repository or organisation like you did before for PRIVATE_KEY. The value is the copied value from ssh-keyscan.

Now we're ready do dive into workflow code.

Workflow Example 1: Deploy Manually

This first workflow is the simplest of all examples. It can be triggered manually – either through the GitHub web UI or through a HTTP request to the GitHub API.

Here is the workflow file. I will go into detail, what each step does below.

# .github/workflows/deploy_manual.yaml
name: Deploy (Manual)

on:
    workflow_dispatch:
        inputs:
            deploy_env:
                description: 'Deploy Environment'
                required: true
                default: 'stag'

jobs:
    deploy:
        name: Deployment
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v2

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: 7.4

            - name: Install Dependencies
              run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

            - name: Deploy
              uses: deployphp/action@master
              with:
                  private-key: ${{ secrets.PRIVATE_KEY }}
                  known-hosts: ${{ secrets.KNOWN_HOSTS }}
                  dep: deploy ${{ github.event.inputs.deploy_env }} -v

What does this workflow do?

# …
on:
    workflow_dispatch:
        inputs:
            deploy_env:
                description: 'Deploy Environment'
                required: true
                default: 'stag'
# …

First the on-keyword. Here we tell GitHub to listen to the workflow_dispatch event which is triggered through a manual process. We set a deploy_env input variable to target different deploy environments. (The ones you will have set up in your deploy.php or deploy.yml file)

# …
jobs:
    deploy:
        name: Deployment
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v2

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: 7.4

            - name: Install Dependencies
              run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

            - name: Deploy
              uses: deployphp/action@master
              with:
                  private-key: ${{ secrets.PRIVATE_KEY }}
                  known-hosts: ${{ secrets.KNOWN_HOSTS }}
                  dep: deploy ${{ github.event.inputs.deploy_env }} -v

Next the job code. What does this?

  1. We tell GitHub Actions to run the job on a machine running the latest Ubuntu version.
  2. Our project is cloned to the Linux machine.
  3. We instruct the shivammathur/setup-php Action to install PHP 7.4.
  4. We install the composer dependencies for our project.
  5. Finally. We tell deployphp/action to deploy our application to the environment we passed to the workflow in the deploy_env input variable.

The local CLI equivalent of the code executed by the deployer Action would be dep deploy stag -v.

This is a basic version of a deploy workflow. The composer dependencies are currently not cached and we don't do anything fancy after the deployment has been successful.

Before we jump to the next few examples, I want to cover how you can now trigger the deployment.

Trigger through GitHub UI

The most straightforward approach to trigger deployment is through github.com.

Go to the "Actions" tab in your repository and click on "Deploy (Manual)" in the workflows list on the left hand side. A "Run workflow" button should appear.

Clicking the button gives you the option to change the branch and the "Deploy Environment" value for the workflow run. The default value for the environment is "stag". Change it to prod if you want to deploy your app to production.

Click "Run workflow" to start the deployment process.

The video shows how to trigger the deployment process manually through the GitHub UI.

After a couple of seconds the deployment run appears on the page.

Trigger through API request

A more versatile option is to trigger the deployment by making an API request to the GitHub API. By using the API, you bypass the tedious work of clicking through the github.com and you can integrate the deployment process in more tools (Slack Bot, iOS Shortcut, Alfred Workflow).
You will have even more ideas where the API request could be made. I won't go further here. [^1: While writing the article, I realized that this is a big topic and I didn't want to blow up the article even further. In a future article I want to share how you can trigger the deployment process through Slack/Telegram/Discord commands.]

As there are many ways on how to make the HTTP request, I share the CURL version here. Adjust it to your liking if you use Guzzle or any other HTTP library.

To make the command work, replace the following values:

  • PRIVATE_ACCESS_TOKEN with a personal access token with the repo scope
  • YOUR_ORG with the organisation name or username of your repository.
  • YOUR_REPOSITORY with the name of the projects repository
curl \
  -X POST \
  -H "Authorization: token PRIVATE_ACCESS_TOKEN" \
  -H "Accept: application/vnd.github.v3+json" \
  https://api.github.com/repos/YOUR_ORG/YOUR_REPOSITORY/actions/workflows/deploy_manual.yaml/dispatches \
  -d '{"ref":"main", "inputs": {"deploy_env": "prod"}}'

If you want to deploy to staging, update the deploy_env value to "stag".

Tipp: If you want to learn more on how to dispatch workflow evens check out "Create a worfklow dispatch event" in the GitHub documentation.

Trigger with gh-CLI

In addition to the web UI and the GitHub API, the workflow can be triggered by using the GitHub CLI.

Execute the following command inside your project folder in your terminal. (You need to be logged-in in gh)

gh workflow run "Deploy (Manual)" -f deploy_env=prod
In this video, I'm triggering the deployment process by calling a composer script which in turn runs the GitHub CLI. After a couple of seconds the deployment run appears in the GitHub UI.

To make triggering the workflow through the terminal easier, I suggest adding a composer script to your project.

# composer.json

"scripts": {
    "deploy:prod": [ "gh workflow run \"Deploy (Manual)\" -f deploy_env=prod"],
    "deploy:stag": [ "gh workflow run \"Deploy (Manual)\" -f deploy_env=stag"],
    
},

You can then run composer run deploy:prod or composer run deploy:stag to trigger the deployment.

Workflow Example 2: Deploy to Production on new Release

This next workflow example is based on our previous example and uses the same "core" steps to deploy our application (clone, install dependencies, deploy).

The difference: this workflow will deploy your application to production when a new release has been created in GitHub.

As before, first the workflow file.

# .github/workflows/deploy_release.yml
name: Deploy (Release)

on:
    release:
        types: [released]

jobs:
    deploy:
        name: Deploy Tag to Production
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v2

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: 7.4

            - name: Install Dependencies
              run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

            - name: Deploy
              uses: deployphp/action@master
              with:
                  private-key: ${{ secrets.PRIVATE_KEY }}
                  known-hosts: ${{ secrets.KNOWN_HOSTS }}
                  dep: deploy prod -v

The workflow looks almost identical to our basic example. Here is what has changed.

on:
    release:
        types: [released]

Instead of listening to the workflow_dispatch-event we are listening to the release event. In particual to the released activity type.

According to the documentation, listening to the released activity type will trigger this workflow only when a stable release has been published.

- name: Deploy
  uses: deployphp/action@master
  with:
      private-key: ${{ secrets.PRIVATE_KEY }}
      known-hosts: ${{ secrets.KNOWN_HOSTS }}
      dep: deploy prod -v

In the "Deploy" step the only difference to our previous example is that we explicitly set the environment to prod. There is no variable available to make this dynamic.

Workflow Example 3: Deploy to Staging on Push

The following example is great if you want to have a continous deployment system.
It will deploy your application to your staging environment when a new commit is made to the default branch.

# .github/workflows/deploy_push.yml
name: Deploy (Push to Branch)

on:
    push:
        branches:
            # You can also change the branch name to `develop`
            - main

jobs:
    deploy:
        name: Deploy staging
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v2

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: 7.4

            - name: Install Dependencies
              run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

            - name: Deploy
              uses: deployphp/action@master
              with:
                  private-key: ${{ secrets.PRIVATE_KEY }}
                  known-hosts: ${{ secrets.KNOWN_HOSTS }}
                  dep: deploy stag -v

As you might have guessed, not much has changed to the previous examples. The only difference is the event we're listening to.

on:
    push:
        branches:
            - main

The workflow listens to the push event. To be exact, it listens to all pushes to the main branch. (Feel free to replace "main" with the branch name that you want to deploy)

If we wouldn't specify the branches-modifier, the workflow would not only be triggered when you push to the main-branch, but when you push and update other branches too. That's not what you want. This will lead to chaos, as GitHub will constantly deploy different versions to staging.

Note: I do not recommend using this workflow in a repository with a lot of "commit-traffic". If your deployment script takes more than 3-4 minutes to run, and every 5-10 minutes a new commit lands on your default branch, you will quickly run through your GitHub Actions CI minutes.

Workflow Example 4: Deploy on Schedule

The next workflow is not triggered by a manual process or in recation to an event on GitHub, but on schedule.

Imagine your team has a nightly server, where the latest version of your project is being deployed every night.
Or you provide a demo application to your users, which is being redeployed every 2 to 3 hours to reset the state of the app.

The schedule feature of GitHub Actions could be helpful here.

name: Deploy (Schedule)

on:
    schedule:
        - cron: '0 0 * * *'

jobs:
    deploy:
        name: Deploy
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v2

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: 7.4

            - name: Install Dependencies
              run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

            - name: Deploy
              uses: deployphp/action@master
              with:
                  private-key: ${{ secrets.PRIVATE_KEY }}
                  known-hosts: ${{ secrets.KNOWN_HOSTS }}
                  dep: deploy nightly --branch=main -v

As you know by now, the workflow looks almost identical to the other examples. Let's have a closer look.

on:
    schedule:
        - cron: '0 0 * * *'

The schedule listener accepts a cron expression as input. The example above runs every day on midnight UTC.
If you need help figuring out the right cron syntax for your project I can recommend crontab guru.

- name: Deploy
  uses: deployphp/action@master
  with:
      private-key: ${{ secrets.PRIVATE_KEY }}
      known-hosts: ${{ secrets.KNOWN_HOSTS }}
      dep: deploy nightly --branch=main -v

In our "Deploy" step I use a different deploy environment: nightly. I explicitly tell deployer to deploy the main branch. This isn't strictly necessary, as workflow runs triggered by the scheduler will check out the default branch by default.
Setting the branch option explicitly gives me more confidence that the right code will be deployed though.

Bonus

Now I would like to show you a few bonus features you could add to your workflows. We start with GitHub environments.

Use GitHub Environments

Environments are shown in the repository sidebar on github.com and can indicate the deploy status of a project.

Screenshot of the example repository on GitHub. Highlighted is the location of the Environments in the sidebar.
GitHub Environments can help you to keep track of when a deployment happend.

The feature isn't new, but got a major overhaul with a recent Actions update. New are environment specific secrets and protection rules. These protection rules allows you to setup a review system, so that another team member has to review the deployment before it goes to production. Or that a certain amount of time has to pass, before a change can be deployed.

These features (protection rules and secrets) are currently in beta and available to public repositories or for GitHub enterprise organisations.
Environments themselfs however, can be referenced in your workflows and can give you a visual indicator if a deployment has been successful or can give you a list when a specific commit has been deployed. (See this list in the example repository)

The following workflow expands on our "manual deployment" workflow and adds 2 environments: "staging" and "production".

# .github/workflows/deploy_manual_with_environments.yml
name: Deploy (Manual + GitHub Environments)

on:
    workflow_dispatch:
        inputs:
            deploy_env:
                description: 'Deploy Environment'
                required: true
                default: 'stag'

jobs:
    deploy-stag:
        if: github.event.inputs.deploy_env == 'stag'
        name: Deploy staging
        runs-on: ubuntu-latest

        environment:
            name: staging
            url: https://stag.example.com

        steps:
            - uses: actions/checkout@v2

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: 7.4

            - name: Install Dependencies
              run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

            - name: Deploy
              uses: deployphp/action@master
              with:
                  private-key: ${{ secrets.PRIVATE_KEY }}
                  known-hosts: ${{ secrets.KNOWN_HOSTS }}
                  dep: deploy ${{ github.event.inputs.deploy_env }} -v

    deploy-prod:
        if: github.event.inputs.deploy_env == 'prod'
        name: Deploy production
        runs-on: ubuntu-latest

        environment:
            name: production
            url: https://example.com

        steps:
            - uses: actions/checkout@v2

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: 7.4

            - name: Install Dependencies
              run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

            - name: Deploy
              uses: deployphp/action@master
              with:
                  private-key: ${{ secrets.PRIVATE_KEY }}
                  known-hosts: ${{ secrets.KNOWN_HOSTS }}
                  dep: deploy ${{ github.event.inputs.deploy_env }} -v

Again, let's dissect the workflow code.

The main difference to all previous examples is that the code above contains 2 jobs: "deploy-stag" and "deploy-prod". These jobs are very similar. The only change between them is an if-statement and the environment declaration.

deploy-prod:
    if: github.event.inputs.deploy_env == 'prod'
    name: Deploy Production
    runs-on: ubuntu-latest

    environment:
        name: production
        url: https://example.com

    steps:
        - uses: actions/checkout@v2

The first line of each job is the if-statement. We check if the deploy_env input value equals to 'prod'. This protects us from running both the staging and production deployment at the same time – as you usually deploy one environment at the time.

Next comes the important part: the environment declaration. Here we give the environment its name and a URL. The URL will later be used in the GitHub UI and allows you to quickly jump to your deployed application.

And that's it. For staging, we check if the deploy_env value is 'stag' and set the GitHub environment to "staging". For production we change the values accordingly.

You might ask yourself, why can't we combine these 2 jobs into a single job and make the environment values dynamic? Unfortunately GitHub Actions isn't that flexible and the if-key word is only available on the job or steps level. (If you find a way to make this more dynamic, let me know!)

Tipp: If you've been experimenting with environments in your GitHub repository and want to get rid of all the past deployments and environments you can use github-deployment-clearer. Enter a personal access token, your organisation name and repository name in the code and after a few seconds all deployments and environments will be removed from the repository. (Unfortunately GitHub doesn't have a UI to delete the environments, that's why you have to resort to their API or third-party tools.)

Bonus: Cache Composer Dependencies

The workflows shown install your projects composer dependencies on every workflow run. To speed things up, I suggest adding the actions/cache-Action to your workflows.

The cache-documentation has an example on how to use it with composer. Let's apply it to our default workflow. Here is our adjusted workflow to cache composer dependencies when a deployment is manually triggered.

# .github/workflows/deploy_manual_with_cache.yml
name: Deploy (Manual; Cache Composer)

on:
    workflow_dispatch:
        inputs:
            deploy_env:
                description: 'Deploy Environment'
                required: true
                default: 'stag'

jobs:
    deploy:
        name: Deployment
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v2

            - name: Setup PHP
              uses: shivammathur/setup-php@v2
              with:
                  php-version: 7.4

            - name: Get Composer Cache Directory
              id: composer-cache
              run: |
                  echo "::set-output name=dir::$(composer config cache-files-dir)"

            - uses: actions/cache@v2
              with:
                  path: ${{ steps.composer-cache.outputs.dir }}
                  key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
                  restore-keys: |
                      ${{ runner.os }}-composer-

            - name: Install Dependencies
              run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

            - name: Deploy
              uses: deployphp/action@master
              with:
                  private-key: ${{ secrets.PRIVATE_KEY }}
                  known-hosts: ${{ secrets.KNOWN_HOSTS }}
                  dep: deploy ${{ github.event.inputs.deploy_env }} -v

What has changed? Before the step that installs our composer dependencies, we added the following 2 steps.

- name: Get Composer Cache Directory
  id: composer-cache
  run: |
      echo "::set-output name=dir::$(composer config cache-files-dir)"

- uses: actions/cache@v2
  with:
      path: ${{ steps.composer-cache.outputs.dir }}
      key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
      restore-keys: |
          ${{ runner.os }}-composer-

The "Get Composer Cache Directory" step runs a shell command to extract the path to the composer cache directory (duh). On your local machine the value would be ~/.composer/cache/files.
The next step uses the cache-Action to either restore or cache the files and folders in ~/.composer/cache/files.

When the workflow runs for the first time, composer will download the dependencies from packagist.org. When the run finshes successfully, the actions/cache will kick in and cache the composer dependencies.

On subsequent workflow runs, where the composer.lock file doesn't change, GitHub Actions will restore the cache and the dependencies will be installed instantly.

Bonus: Sentry Release Tracking

Before I let you go, one last bonus section (I promise).
Another benefit of having the deploy process running on GitHub Actions is the access to repo or organisation wide secrets.

My team and I use these org wide secrets to track our deployments with Sentry's release feature. Sentry will send a neat email to all developers who contributed and lets them know that their code made it to production. (In addition, the error tracking is a bit more detailled.)

The Sentry team published their own GitHub Action to make this super easy. Add the following block at the end of your workflow, replace the values for SENTRY_ORG and SENTRY_PROJECT and add your Sentry auth token to your repository or organisation secrets.

- name: Create Sentry Release
  uses: getsentry/action-release@v1
  env:
      SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
      SENTRY_ORG: sentry_org_name
      SENTRY_PROJECT: sentry_project_name
  with:
      environment: ${{ github.event.inputs.deploy_host }}
      set_commits: auto

Next time your app is being deployed, you will receive an email notification with a list of the commits that went live.

How I use this approach in my projects

Not all my personal projects use deployer or the GitHub Actions deploy method … yet. For a few projects it doesn't make sense to add all this complexity. Many of my side projects are fine to go down for a couple of seconds or minutes when the app is deployed, through Laravel Forge for example, as I am the sole user.

On the projects that do use the GitHub Actions approach, I've settled on using the gh-CLI to trigger the deployment. (A shell alias or composer script like the mentioned composer run deploy:prod makes using it more convenient).
To deploy 3.screeenly.com, I've written an iOS Shortcut and Alfred Workflow that interacts with the GitHub API. (Let me know if you're interested in those scripts)

At work, my team and I are deciding what the best approach is. For now, we've settled on using the gh-CLI to deploy our apps. We're currently exploring the idea of adding a deploy command to our Slack channels though. This would allow developers to deploy an application with a simple /deploy prod in an #ops channel. (I have no idea how to tackle this problem yet. You know how? Let me now!).

I think the most important part is, to keep in mind that all this (*gestures at the text above*) adds a lot of complexity to your infrastructure. GitHub Actions could go down or introduce a breaking change that forces you to update the workflows.

As a final note on this, I would suggest you make sure that you are still able to deploy your application from your local terminal by using the deployer CLI and by running dep deploy prod.

Outro and Personal Opinion

Phew! Thanks for reading so far. I hope you learned a thing or two about GitHub Actions or deployments while reading this article.
While researching and testing the workflows in this article I sure did learn a couple of new things.

If you have any questions, let me know via Twitter, through email or by joining the discussion in my AMA repository.

If I could spark your interest in GitHub Actions and you would like to automate common tasks in your GitHub repositories I've written on how to auto merge Dependabot PRs or how to lint and fix your code with php-cs-fixer and prettier.

Finally, if you would like to read more about this topic from me in the future, consider sponsoring me on GitHub Sponsors.

Outlook

I had a lot of fun writing this article. As mentioned, I would like to explore how to properly incorporate the deployment process in tools like Slack, Microsoft Teams, Telegram or whatever chat platform a team is using (eg. "ChatOps").

As this article is quite long, I decided that I will pack my findings in an accompanianing article … when I'm ready to share more.

FAQ

What are the security implications of deploy apps in this way?

For one, you have to trust GitHub that they keep their Actions infrastructure secure and that no other party can spy on a workflow run.

Another attack vector could be the way on how you decide to trigger the deployment. If you stick to GitHubs native events like push or release you should be safe.

If you decide to use a deploy workflow that listens to the workflow_dispatch-event, the risk increases.
If you then decide to trigger the event through API requests through another app (Slack, Alfred, iOS Shortcuts, *insert app name here*), then you have trust these apps that they are not compromised.

In the end, you and your team have to decide if the switch to deploy your apps through a third-party is better than running a CLI command locally on your machine.

Why do you not use a single Workflow to cover all scenarios?

I currently like to have workflows that do one thing and are small. This makes debugging or explaining a workflow to others easier.

Technically you could combine the mentioned example workflows above into one "super" workflow, but you would then have to riddle it with if-statements to check, wether the step or job in that workflow should be run for a triggered event.

Why do you not have a single Workflow which runs your tests and then deploys your app?

As mentioned above, I personally prefer to have dedicated workflows for each task. Even if this means, that I waste precious CI minutes.

However, in the repository which holds this blog, I've started to experiment with creating a single huge workflow, that tests that the site can be built and then triggers the deployment and other subsequent jobs.

If you think you found the right way to write workflows or are interested in combining multiple workflows into one: Let me know!

Why didn't you cover GitHub Environments protection rules and required reviewers in depth?

As of time of writing this article (May 2021) GitHub Environment and protection rules are in beta and available to public repositories or GitHub Enterprise plans.
As I assume not all repositories of my readers are either public or using GitHub Enterprise, I've kept these features out of this article.

In addition, "Required reviewers", "wait timer" and "deployment branches" are features that do not need to be configured in a workflow file but rather in the UI on github.com.

I assume once the features are out of beta and available to more users, it will be really easy to – for example – add required reviewers to your application. If you use a workflow that references environments, I assume you have to create/select that environment in the repository settings and add the required reviewers.

I will update this article accordingly once the feature is made generally available.

What about GitHub Deploy Keys?

The GitHub documentation mentions "Deploy Keys" as a way to give a server access to repository through a read-only SSH key.

I haven't used or suggested deploy keys in the article above for the PRIVATE_KEY secret, as they solve a different problem. Deploy keys are here to allow your servers to download/clone a GitHub repository; but not to allow GitHub Actions to connect to your server through SSH.

You could technically use the SSH keys you've designated as deploy keys in the PRIVATE_KEY secret, but I would advise against mixing those keys.

Webmentions