Building Drupal Modules with GitHub Actions

Sometimes CI architectures are more a product of circumstance than we'd like. The strategy described in this post is a prime example of that.

Many Drupal modules don't require a build step, typically they are just some PHP, yaml, and maybe some Twig/JS/CSS files all checked-in. Those files then directly form the contents of the release for the module.'s infrastructure is tailored to that style of module. Internally uses an instance of GitLab to host the source code for each module. Project maintainers tag releases in GitLab then manually create releases at the project level which then become the installable versions for the Composer "drupal/" prefix. (See the Drupal docs on using Composer.)

Unfortunately, there's another style of module which doesn't seem to be well served by the current infrastructure - modules which need an arbitrary build step. The example I am going to use in this post is a module that includes some Javascript code that is built with Webpack.

Project Setup / Code

To start off, create a new GitHub repository and module project. For clarity and convenience, I recommend making the GitHub repository name and the project "short name" the same. For that, the name needs to follow the Drupal conventions for module naming - basically just alphanumeric characters and underscores. I've chosen to call my example for this tutorial "symbioquine_dot_net_built_drupal_module_example".

I let GitHub generate a default branch, readme, and .gitignore file. Then I renamed the branch from main to release - this will be important later since our GitHub workflow will update that branch using the results of our automated build when we push specific tags to the development branch.

git clone
git checkout -b development


// Wait until all attached Drupal libraries get loaded
document.addEventListener('DOMContentLoaded', () => {
  document.querySelector('#example-page-app').innerHTML = "Hello world!";


  "name": "symbioquine_dot_net_built_drupal_module_example",
  "version": "1.0.0",
  "description": "A module showing how to automatically build JS code with Webpack and push releases to",
  "license": "GPL-3.0-or-later",
  "repository": {
    "type": "git",
    "url": ""
  "scripts": {
    "build": "webpack --config webpack.config.js --mode production"
  "devDependencies": {
    "webpack": "^5.36.0",
    "webpack-cli": "^4.6.0"
  "dependencies": {


module.exports = {
  entry: {
    'built_drupal_module_example': {
      'import': `${__dirname}/src/main.js`,
  output: {
    path: `${__dirname}/drupal_module_src/js`,
    filename: '[name].js',
    clean: true,


  "name": "symbioquine/symbioquine_dot_net_built_drupal_module_example",
  "description": "A module showing how to automatically build JS code with Webpack and push releases to",
  "type": "drupal-module",
  "homepage": "",
  "authors": [
      "name": "Symbioquine",
      "homepage": "",
      "role": "Maintainer"
  "support": {
    "issues": "",
    "source": ""
  "license": "GPL-3.0-or-later",
  "minimum-stability": "dev"


name: Built Drupal Module Example
description: This module shows an example of how to automatically build JS code with Webpack and push releases to
type: module
package: Example
core_version_requirement: ^9


      preprocess: false
      minified: true


  path: '/symbioquine_dot_net_built_drupal_module_example'
    _controller: symbioquine_dot_net_built_drupal_module_example.top_level_controller:content
    _title: 'Example Page'
    _permission: 'access content'


    class: Drupal\symbioquine_dot_net_built_drupal_module_example\Controller\ExamplePageController
    arguments: {}



namespace Drupal\symbioquine_dot_net_built_drupal_module_example\Controller;

use Drupal\Core\Controller\ControllerBase;

 * Defines ExamplePageController class.
class ExamplePageController extends ControllerBase {

   * Constructs a new ExamplePageController object.
  public function __construct() {

   * Top-level handler for demo page requests.
  public function content() {
    return [
      'app' => [
        '#markup' => '<div id="example-page-app"></div>',
        '#attached' => [
          'library' => [



name: Build
    # Sequence of patterns matched against refs/tags
      - 'unbuilt-v*' # Push events to matching unbuilt-v*, i.e. unbuilt-v1.0.0

    name: Create Release
    runs-on: ubuntu-latest
      - name: Set RELEASE_VERSION environment variable
        run: echo "RELEASE_VERSION=${GITHUB_REF:19}" >> $GITHUB_ENV

      - name: Checkout code
        uses: actions/checkout@v2
          path: main

      - name: Checkout release branch
        uses: actions/checkout@v2
          path: release
          ref: release
          fetch-depth: 0

      - uses: actions/setup-node@v1
          node-version: '16.x'

      - name: NPM Build
        run: |
          cd ./main/
          npm ci
          npm run build

      - name: Copy Module to Release Working Dir
        run: |
          # Don't let stale build artifacts accumulate in our release branch
          rm -rf ./release/js
          cp ./main/{,,LICENSE} ./release/
          cp -r ./main/drupal_module_src/* ./release/

      - name: Push Changes to Release Branch and Tag
        run: |
          cd ./release
          git config github-actions
          git config
          git add .
          git commit -m "Release ${{ env.RELEASE_VERSION }}"
          git tag ${{ env.RELEASE_VERSION }}
          git push --atomic origin HEAD:release ${{ env.RELEASE_VERSION }}

      - name: Setup SSH Keys and known_hosts for
          SSH_AUTH_SOCK: /tmp/ssh_agent.sock
        run: |
          mkdir -p ~/.ssh/
          echo "${{ secrets.DRUPAL_DOT_ORG_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
          ssh-agent -a $SSH_AUTH_SOCK > /dev/null
          ssh-add - <<< "${{ secrets.DRUPAL_DOT_ORG_SSH_PRIVATE_KEY }}"

      - name: Push Changes to Release Branch and Tag on Gitlab
          SSH_AUTH_SOCK: /tmp/ssh_agent.sock
        run: |
          cd ./release
          git config github-actions
          git config
          git remote add drupal-dot-org
          git fetch drupal-dot-org
          git push --tags --force drupal-dot-org 'HEAD:refs/heads/release'

Test Build

npm install
npm run build GitLab Deploy Key Setup

Add a new Deploy key to the GitLab for the project. For my example this is at

I've been using the RSA key generation instructions from


$ ssh-keygen -t rsa -b 2048 -C "symbioquine_dot_net_built_drupal_module_example Gitlab deploy key"
Generating public/private rsa key pair.
Enter file in which to save the key (/home/symbioquine/.ssh/id_rsa): /home/symbioquine/.ssh/symbioquine_dot_net_built_drupal_module_example
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/symbioquine/.ssh/symbioquine_dot_net_built_drupal_module_example
Your public key has been saved in /home/symbioquine/.ssh/
The key fingerprint is:
SHA256:YmArT17dAmWhTRKPYL6DHrBBktL6pC1hbTf1f+edWaI symbioquine_dot_net_built_drupal_module_example Gitlab deploy key
The key's randomart image is:
+---[RSA 2048]----+
|.+  o o.=.       |
|= .o . @         |
|+.. + = +        |
|o=.= * o o       |
|o*= * = S o      |
|o.o* + . . . ....|
| .. o       ..o.=|
|            E  +.|
|                 |

Then I copied the contents of /home/symbioquine/.ssh/ into a new GitLab deploy key. Make sure to check the box that says "Grant write permissions to this key" since we'll be using that key to push releases into the GitLab repo.

GitHub Secrets Setup

Add two new secrets for GitHub Actions. For my example these are added at

First I copied the contents of /home/symbioquine/.ssh/symbioquine_dot_net_built_drupal_module_example into a secret named DRUPAL_DOT_ORG_SSH_PRIVATE_KEY.

Next I copied the output of running ssh-keyscan into a secret named DRUPAL_DOT_ORG_SSH_KNOWN_HOSTS.

Tag and Push Initial Release

echo "node_modules" >> .gitignore
echo "drupal_module_src/js" >> .gitignore
git add -A
git commit -m "Release 1.0.0"
git tag unbuilt-v1.0.0
git push --atomic origin HEAD:development unbuilt-v1.0.0

Create release

Once the GitHub Actions workflow completes a new 1.0.0 tag will have been created on the GitLab for the project.

Then I go to the project page on and create the corresponding release.


Now we have a Drupal module with JS code built via GitHub actions and installable via the drupal/ composer prefix;

composer require drupal/symbioquine_dot_net_built_drupal_module_example
drush en symbioquine_dot_net_built_drupal_module_example

Once installed, the example page can be accessed under the Drupal site at /symbioquine_dot_net_built_drupal_module_example. e.g.

The full source of this example can be found in the GitHub repository at

For further reference, here are a number of other modules that I maintain which use variations of this same strategy;

Have fun and build some cool stuff!