GPG Encryption for GitHub Actions Files/Variables

This post describes a method for securely passing workflow variables/files between jobs (or steps) using GPG encryption.

In summary, the following scenarios are covered:

  • Securing a variable between various steps of the same job
  • File encryption to securely pass data between different jobs

Prerequisites and Assumptions

Examples presented use the following values during the GPG key creation:

Passphrase: mypassphrase
Name: GPG GHA
Email: myemail@mydomain.com

Generate GPG Key

Generate a gpg key:

$ gpg --full-generate-key

Respond to the interactive questions, and enter a passphrase for the key when prompted.

gpg (GnuPG) 2.2.19; Copyright (C) 2019 Free Software Foundation, Inc.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Please select what kind of key you want:
   (1) RSA and RSA (default)
   (2) DSA and Elgamal
   (3) DSA (sign only)
   (4) RSA (sign only)
  (14) Existing key from card

Your selection? 1

RSA keys may be between 1024 and 4096 bits long.
What keysize do you want? (3072) 4096
Requested keysize is 4096 bits

Please specify how long the key should be valid.
         0 = key does not expire
      <n>  = key expires in n days
      <n>w = key expires in n weeks
      <n>m = key expires in n months
      <n>y = key expires in n years
Key is valid for? (0) 365

GnuPG needs to construct a user ID to identify your key.

Real name: GPG GHA
Email address: myemail@mydomain.com
Comment: GPG key for Actions
You selected this USER-ID:
    "GPG GHA (GPG key for Actions) <myemail@mydomain.com>"

Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit? o

Once the key has been generated, the following output should appear:

gpg: key ************** marked as ultimately trusted
gpg: revocation certificate stored as '/home/******/.gnupg/openpgp-revocs.d/******************************.rev'
public and secret key created and signed.

pub   rsa4096 YYYY-MM-DD [SC] [expires: YYYY-MM-DD]
      ******************************
uid                      GPG GHA (GPG key for Actions) <myemail@mydomain.com>
sub   rsa4096 YYYY-MM-DD [E] [expires: YYYY-MM-DD]

Note: a key revocation certificate has been generated at the following target, i.e,:

gpg: revocation certificate stored as '/home/******/.gnupg/openpgp-revocs.d/******************************.rev'

Check our keyring to ensure the public key has been created.

$ gpg --list-keys

Output:

gpg: checking the trustdb
gpg: marginals needed: 3  completes needed: 1  trust model: pgp
gpg: depth: 0  valid:   3  signed:   0  trust: 0-, 0q, 0n, 0m, 0f, 3u
gpg: next trustdb check due at YYYY-MM-DD
/home/******/.gnupg/pubring.kbx
----------------------------
pub   rsa4096 YYYY-MM-DD [SC] [expires: YYYY-MM-DD]
      ******************************
uid           [ultimate] GPG GHA (GPG key for Actions) <myemail@mydomain.com>
sub   rsa4096 YYYY-MM-DD [E] [expires: YYYY-MM-DD]

List our secret key:

$ gpg --list-secret-keys

Output:

/home/******/.gnupg/pubring.kbx
----------------------------
sec   rsa4096 YYYY-MM-DD [SC] [expires: YYYY-MM-DD]
      ******************************
uid           [ultimate] GPG GHA (GPG key for Actions) <myemail@mydomain.com>
ssb   rsa4096 YYYY-MM-DD [E] [expires: YYYY-MM-DD]

Export Secret Key

Export the secret key to output file enc-private.key.

$ gpg --armor --export-secret-key myemail@mydomain.com > enc-private.key

Defining GPG Key Details in GitHub Actions Secrets

We can choose to setup the GPG credentials at either the repository, organization or enterprise level. For this exercise, we’ll perform the setup for a repository.

Using the sample values mentioned earlier, the following repository actions secrets are created:

GPG_PASSPHRASEmypassphrase
GPG_SECRET_KEYGPG private key, (contents of file enc-private.key)
GPG_USER_EMAILmyemail@mydomain.com
Secrets Passphrase
Secrets Gpg Key
Secrets Email

We can now reference these values in our workflows using the secrets context:

${{ secrets.GPG_PASSPHRASE }}
${{ secrets.GPG_SECRET_KEY }}
${{ secrets.GPG_USER_EMAIL }}

Sample 1: Encrypt/Decrypt within the Same Workflow Job

The first sample workflow gpg-example.yml contains a single job with the following steps:

id: encrypt

  • generates a random 20-character string and stores value in variable named random_secret
random_secret="$(head -n1 /dev/urandom | od -A n -x -N10 --endian=big | tr -d '\n' | tr -d ' ')"
  • encrypts the variable using our GPG key

id: decrypt

  • decrypts the variable and uses it

gpg-example.yml

name: GPG Encrypt/Decrypt Sample Job

on:
  workflow_dispatch:

jobs:
  gpg-encrypt-decrypt:
    runs-on: [self-hosted]
    steps:
      - name: Generate random secret and encrypt
        id: encrypt
        run: |
          gpg --allow-secret-key-import \
              --batch --import <<<"${{ secrets.GPG_SECRET_KEY }}"

          echo -e "5\ny\n" | \
            gpg --batch \
                --command-fd 0 \
                --edit-key ${{ secrets.GPG_USER_EMAIL }} \
                trust quit

          random_secret="$(head -n1 /dev/urandom | od -A n -x -N10 --endian=big | tr -d '\n' | tr -d ' ')"

          random_secret_encrypt="$(echo "$random_secret" | \
                                  gpg --encrypt \
                                      --batch \
                                      --pinentry-mode loopback \
                                      -r "${{ secrets.GPG_USER_EMAIL }}" \
                                      --passphrase "${{ secrets.GPG_PASSPHRASE }}" \
                                      --armor | \
                                      base64 --wrap 0 \
                                  )"
          echo "random_secret_encrypt=${random_secret_encrypt}" >> $GITHUB_OUTPUT

      - name: Decrypt the secret and use it
        id: decrypt
        run: |
          gpg --allow-secret-key-import \
              --batch \
              --import <<<"${{ secrets.GPG_SECRET_KEY }}"

          random_secret_decrypt="$(echo "${{ steps.encrypt.outputs.random_secret_encrypt }}" | \
                                  base64 --decode | \
                                  gpg --no-tty \
                                      --decrypt \
                                      --batch \
                                      --pinentry-mode loopback \
                                      -r "${{ secrets.GPG_USER_EMAIL }}" \
                                      --quiet \
                                      --passphrase "${{ secrets.GPG_PASSPHRASE }}" \
                                  )"

          ## Decrypted value can be used from here onwards
          echo now use the decrypted variable '$random_secret_decrypt'

Output log from a sample run is shown below:

Workflow Log
Encrypt Step
Decrypt Step

The decrypted variable value is never echoed in output logs, even if the option “Enable debug logging” is selected when triggering the workflow.

Sample 2: File Encryption to Pass Data between Different Jobs

The workflow (share-files-between-jobs-gpg.yml) presented in this section consists of 2 jobs.

encrypt_file:

  • uses the same command as the previous workflow to create a random 20 byte string
  • saves output to file my-secrets.txt
head -n1 /dev/urandom | od -A n -x -N10 --endian=big | tr -d '\n' | tr -d ' ' > my-secrets.txt
  • our gpg key is then used to encrypt the contents into output file my-secrets.gpg
  • encrypted file is then “passed” on to the next job using the actions/upload-artifact action

decrypt_file:

  • uses the actions/download-artifact action to “receive” the encrypted file from the previous job
  • decrypts the file using our gpg key

share-files-between-jobs-gpg.yml

name: GPG Share data between jobs

on:
  workflow_dispatch:

jobs:
  encrypt_file:
    runs-on: self-hosted
    steps:
      - name: Generate GPG encrypted file
        id: encrypt
        run: |
          gpg --allow-secret-key-import \
              --batch --import <<<"${{ secrets.GPG_SECRET_KEY }}"

          echo -e "5\ny\n" | \
            gpg --batch \
                --command-fd 0 \
                --edit-key ${{ secrets.GPG_USER_EMAIL }} \
                trust quit

          if test -f my-secrets.txt; then
            rm my-secrets.txt
          fi

          if test -f my-secrets.gpg; then
            rm my-secrets.gpg
          fi

          head -n1 /dev/urandom | od -A n -x -N10 --endian=big | tr -d '\n' | tr -d ' ' > my-secrets.txt
          gpg --encrypt \
              --batch \
              --recipient ${{ secrets.GPG_USER_EMAIL }} \
              --passphrase ${{ secrets.GPG_PASSPHRASE }} \
              -o my-secrets.gpg my-secrets.txt

      - name: Send/upload encrypted file to next job
        uses: actions/upload-artifact@v4
        with:
          name: my-secrets
          path: my-secrets.gpg

  decrypt-file:
    needs: encrypt_file
    runs-on: self-hosted
    steps:
      - name: Download encrypted file
        uses: actions/download-artifact@v4
        with:
          name: my-secrets

      - name: Decrypt the file and use it
        id: decrypt
        run: |
          gpg --allow-secret-key-import \
              --batch \
              --import <<<"${{ secrets.GPG_SECRET_KEY }}"

          random_secret_decrypt="$(gpg \
                                    --no-tty \
                                    --decrypt \
                                    --batch \
                                    --quiet \
                                    --pinentry-mode loopback \
                                    --recipient ${{ secrets.GPG_USER_EMAIL }} \
                                    --quiet \
                                    --passphrase ${{ secrets.GPG_PASSPHRASE }} \
                                    my-secrets.gpg 2>/dev/null \
                                    )"

          ## Decrypted value can be used from here onwards
          echo now use the decrypted variable $random_secret_decrypt