Compare changes to encrypted files without revealing secrets in a GitHub Actions pull request workflow!
Recently I've worked on something I can describe as a very fun solution that has relieved much stress. On a project with frequent updates to encrypted environment files, it became tedious to review pull requests with changes to these files. We needed to manually verify changes locally, and some of them could be missed since GitHub can't show diffs on encrypted files. As a remedy, I thought, "why not just diff which keys were changed within the file?" And luckily, we can compare files between branches with GitHub Actions! The workflow below will demonstrate an example of how this is possible using the exciting power of the pull_request trigger: it be set to run only when certain files are changed in a PR! Problem statement: I need to compare changes made to an encrypted file within a pull request. The file is stored as key-value pairs of information, similar to a flat JSON structure, or an .env file. The solution: During a pull request, use a GitHub Actions workflow to post a comment that displays which keys were changed without outputting any sensitive data. Building a GitHub Workflow The process A pull request is opened that contains edits to an encrypted file, which triggers the workflow. It then will: Check out the repository from the current (source) branch In a subdirectory, check out the repository from the target branch (where you want to merge) With a custom-built JavaScript command, decrypt the files using a stored repository secret, and then perform a diff on them to detect Additions, Deletions, and Modifications. More on this below! Report back only non-sensitive key names of anything changed as a new JSON object Share the JSON object to a bot that can post a comment on the pull request of those changes Let's get to work! Set up the workflow file The trigger For this workflow, we are going to assume changes are being made to .env files, which are text files of key-value pairs of information. They look like this: EXAMPLE_KEY=value MY_SECRET=abcd1234! Whenever this file is updated and committed back to the repository, we want the workflow to run and verify what keys were changed. Something really really cool with workflow triggers is that can be set to run against individual file or directory changes within pull requests: on: pull_request: paths: - '.env.enc' For the above, the workflow will only run when 1. the action is a pull request, and 2. a change happens to the .env.enc file at the root of the project directory. Next, two jobs will be set up. The first will handle producing the diff based on changes to that file. The second will post a formatted comment on the PR based on the diff. The first job: get-file-differences This job will checkout both the current branch's full repository, and then only the encrypted environment file from the base branch. Then, it will use a custom JavaScript command to decrypt, diff the files, and then provide outputs back to the workflow. get-file-differences: runs-on: ubuntu-latest outputs: message: ${{ steps.produce-diff.outputs.message }} steps: # Use the current branch's repository to run all commands - name: Checkout head branch uses: actions/checkout@v4 # Checkout only the file from the base branch into a new directory named `base` - name: Checkout file from base branch uses: actions/checkout@v4 with: ref: ${{ github.base_ref }} path: base sparse-checkout-cone-mode: false # submodules & sparse-checkout allow checking out only a portion of the repository into another directory! submodules: true sparse-checkout: | .env.enc # Run a clean install of the repository - run: npm ci # This is a custom JS script that can diff the files and output the results # It will be described below in more detail! - run: npm run diff-env-files id: produce-diff env: BASE_DOTENVENC_FILE_PATH: ./base/.env.enc CURRENT_DOTENVENC_FILE_PATH: ./.env.enc DOTENVENC_PASS: ${{ secrets.DOTENVENC_PASS }} #Needed for decryption by dotenvenc The second job: post-or-edit-comment The second job will use an action that can post comments as a bot to the pull request using the action from peter-evans/create-or-update-comment. For this job, we will be adding or amending a single comment only, so if more changes are pushed to the pull request, the original comment will be updated instead of a new one published. It will wait on the first job to finish, and then use its message output to use in the comment. post-or-edit-comment: runs-on: ubuntu-latest # These permissions are necessary for the `create-or-update-comment` action to work! permissions: issues: write pull-requests: write needs: [ get-file-differences ] steps: - n

Recently I've worked on something I can describe as a very fun solution that has relieved much stress.
On a project with frequent updates to encrypted environment files, it became tedious to review pull requests with changes to these files. We needed to manually verify changes locally, and some of them could be missed since GitHub can't show diffs on encrypted files. As a remedy, I thought, "why not just diff which keys were changed within the file?" And luckily, we can compare files between branches with GitHub Actions!
The workflow below will demonstrate an example of how this is possible using the exciting power of the pull_request
trigger: it be set to run only when certain files are changed in a PR!
Problem statement:
I need to compare changes made to an encrypted file within a pull request. The file is stored as key-value pairs of information, similar to a flat JSON structure, or an
.env
file.
The solution:
During a pull request, use a GitHub Actions workflow to post a comment that displays which keys were changed without outputting any sensitive data.
Building a GitHub Workflow
The process
A pull request is opened that contains edits to an encrypted file, which triggers the workflow. It then will:
- Check out the repository from the current (source) branch
- In a subdirectory, check out the repository from the target branch (where you want to merge)
- With a custom-built JavaScript command, decrypt the files using a stored repository secret, and then perform a diff on them to detect Additions, Deletions, and Modifications. More on this below!
- Report back only non-sensitive key names of anything changed as a new JSON object
- Share the JSON object to a bot that can post a comment on the pull request of those changes
Let's get to work!
Set up the workflow file
The trigger
For this workflow, we are going to assume changes are being made to .env
files, which are text files of key-value pairs of information. They look like this:
EXAMPLE_KEY=value
MY_SECRET=abcd1234!
Whenever this file is updated and committed back to the repository, we want the workflow to run and verify what keys were changed.
Something really really cool with workflow triggers is that can be set to run against individual file or directory changes within pull requests:
on:
pull_request:
paths:
- '.env.enc'
For the above, the workflow will only run when 1. the action is a pull request, and 2. a change happens to the .env.enc
file at the root of the project directory.
Next, two jobs will be set up. The first will handle producing the diff based on changes to that file. The second will post a formatted comment on the PR based on the diff.
The first job: get-file-differences
This job will checkout both the current branch's full repository, and then only the encrypted environment file from the base branch. Then, it will use a custom JavaScript command to decrypt, diff the files, and then provide outputs back to the workflow.
get-file-differences:
runs-on: ubuntu-latest
outputs:
message: ${{ steps.produce-diff.outputs.message }}
steps:
# Use the current branch's repository to run all commands
- name: Checkout head branch
uses: actions/checkout@v4
# Checkout only the file from the base branch into a new directory named `base`
- name: Checkout file from base branch
uses: actions/checkout@v4
with:
ref: ${{ github.base_ref }}
path: base
sparse-checkout-cone-mode: false
# submodules & sparse-checkout allow checking out only a portion of the repository into another directory!
submodules: true
sparse-checkout: |
.env.enc
# Run a clean install of the repository
- run: npm ci
# This is a custom JS script that can diff the files and output the results
# It will be described below in more detail!
- run: npm run diff-env-files
id: produce-diff
env:
BASE_DOTENVENC_FILE_PATH: ./base/.env.enc
CURRENT_DOTENVENC_FILE_PATH: ./.env.enc
DOTENVENC_PASS: ${{ secrets.DOTENVENC_PASS }} #Needed for decryption by dotenvenc
The second job: post-or-edit-comment
The second job will use an action that can post comments as a bot to the pull request using the action from peter-evans/create-or-update-comment. For this job, we will be adding or amending a single comment only, so if more changes are pushed to the pull request, the original comment will be updated instead of a new one published. It will wait on the first job to finish, and then use its message
output to use in the comment.
post-or-edit-comment:
runs-on: ubuntu-latest
# These permissions are necessary for the `create-or-update-comment` action to work!
permissions:
issues: write
pull-requests: write
needs: [ get-file-differences ]
steps:
- name: Find Comment
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: 'Updates to ".env" file in this pull request'
- name: Add or replace comment with newest file differences
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
# Updates to ".env" file in this pull request
${{ needs.get-file-differences.outputs.message }}
edit-mode: replace
Here is the complete YAML file:
# ./github/workflows/diff-env-files.yml
name: Display differences between ".env.enc" files
description: |
This annotates the current pull request with differences between the ".env.enc" files in the base and head branches.
It output the key names that have been added, removed, and modified on the head branch as a comment on the branch.
on:
pull_request:
paths:
- '.env.enc'
jobs:
get-file-differences:
runs-on: ubuntu-latest
outputs:
message: ${{ steps.produce-diff.outputs.message }}
steps:
- name: Checkout head branch
uses: actions/checkout@v4
- name: Checkout file from base branch
uses: actions/checkout@v4
with:
ref: ${{ github.base_ref }}
path: base
sparse-checkout-cone-mode: false
submodules: true
sparse-checkout: |
.env.enc
- run: npm ci
- run: npm run diff-env-files
id: produce-diff
env:
BASE_ENV_ENC_FILE_PATH: ./base/.env.enc
CURRENT_ENV_ENC_FILE_PATH: ./.env.enc
DOTENVENC_PASS: ${{ secrets.DOTENVENC_PASS }}
post-or-edit-comment:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
needs: [ get-file-differences ]
steps:
- name: Find Comment
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: 'Updates to ".env" file in this pull request'
- name: Add or replace comment with newest file differences
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
# Updates to ".env" file in this pull request
${{ needs.get-file-differences.outputs.message }}
edit-mode: replace
Set up the custom JavaScript command for diff-env-files
Warning: because this command handles these files in plaintext, take extra caution to not log any information you do not want displayed in the pull request!
With workflow written, let's get the JavaScript command in place. It will handle decrypting the files, diffing them for Additions, Deletions, and Modifications, and setting outputs to the GitHub Actions step. The outputs are a markdown message used for posting the comment, and the JSON file created of the diffs.
Notes about the packages:
-
dotenvenc package will handle encryption and decryption of
.env
files in this example. - @actions/core contains GHA workflow helpers for JavaScript! Here is an example of the script:
const path = require('path');
const dotenvenc = require('@tka85/dotenvenc');
const core = require('@actions/core');
function performDiff(baseBranchFile, currentBranchFile) {
const getKeyDifferences = (base, current) => {
const baseKeys = Object.keys(base);
const currentKeys = Object.keys(current);
const diffs = [];
for (const baseKey of baseKeys) {
if (!currentKeys.includes(baseKey)) diffs.push(baseKey);
}
return diffs;
};
const removed = (base, current) => {
return getKeyDifferences(base, current);
};
const added = (base, current) => {
return getKeyDifferences(current, base);
};
const modified = (base, current) => {
const baseKeys = Object.keys(base);
const currentKeys = Object.keys(current);
const diffs = [];
for (const baseKey of baseKeys) {
if (currentKeys.includes(baseKey)) {
if (JSON.stringify(base[baseKey]) !== JSON.stringify(current[baseKey])) {
diffs.push(baseKey);
}
}
}
return diffs;
};
return {
Added: added(baseBranchFile, currentBranchFile),
Removed: removed(baseBranchFile, currentBranchFile),
Modified: modified(baseBranchFile, currentBranchFile),
};
}
/**
* Writes the diffs of each key in a readable GitHub Markdown Format
* @example Changes for each diff type
* ## Added
* - MY_API_KEY
* - CUSTOMER_KEY
* ## Removed
* - MY_PASSWORD
* ## Modified
* - MY_API_URL
*
* @example Changes for additions only
* ## Added
* - MY_API_KEY
* - CUSTOMER_KEY
*/
function getAsMarkdown(diffs) {
const createList = (items) => {
return items.map((i) => `- ${i}`).join('\n');
};
return Object.entries(diffs)
.map(([key, vals]) => {
//Only build if there are values for the key.
if (vals.length > 0) {
return [`## ${key}`, createList([vals].flat()), '\n'].join('\n');
}
})
.join('')
.trim();
};
async function main() {
//base-ref (target branch) file
const baseBranchFile = await dotenvenc.decrypt({encryptedFile: path.resolve(process.env.BASE_DOTENVENC_FILE_PATH)})
//head-ref (source branch) file
const currentBranchFile = await dotenvenc.decrypt({encryptedFile: path.resolve(process.env.CURRENT_DOTENVENC_FILE_PATH)})
//Get diffs
const diffs = performDiff(baseBranchFile, currentBranchFile);
const hasDiffs = Object.values(diffs).some((d) => d.length > 0);
//Add outputs to GitHub Actions workflow
const message = hasDiffs ?
getAsMarkdown(diffs) :
'No differences exist between the files.'
core.setOutput('diffs', diffs);
core.setOutput('message', message);
}
main();
Three environment variables are needed, which can be passed in directly from the workflow:
-
BASE_ENV_ENC_FILE_PATH
: the path to the base branch's encrypted file. This is set in the workflow as./base/.env.enc
-
CURRENT_ENV_ENC_FILE_PATH
: the path to the current branch's encrypted file. This is set in the workflow as./.env.enc
-
DOTENVENC_PASS
: this is used to decrypt the files, as required by the package dotenvenc. In my demo repository, I have it set as a repo secret.
When this script is run, no output will be displayed to the user; instead, outputs are set directly for the GitHub Actions workflow to consume.
Finally, we should add it to our package.json
for ease-of-use:
"scripts": {
"encrypt": "./node_modules/.bin/dotenvenc -e",
"decrypt": "./node_modules/.bin/dotenvenc -d",
"diff-env-files": "node diff-env-files.js"
}
Note: This is just an example -- you could separate the decryption and diff processes if needed. The main portion is just comparing a flat JSON file structure!
Bonus: Hiding sensitive keys
Let's assume you have a list of keys that, no matter if they are edited, you cannot display the key name in the output. No worries! We can edit the diffs
before setting it to the output using a replacer function, and use a comma-delimited list of keys to hide provided to the process.env
.
In the JavaScript command file, let's add this function:
const hideKeys = (diffs) => {
let sensitiveKeysChanged = 0
//@example: DB_PASSWORD,SECRET_TOKEN
const SENSITIVE_KEYS = `${process.env.DOTENV_SENSITIVE_KEYS}`.split(',')
Object.entries(diffs).map(([section, keys]) => {
keys.map(k => {
if (SENSITIVE_KEYS.includes(k)) {
sensitiveKeysChanged++;
const keyIndex = diffs[section].indexOf(k)
diffs[section].splice(keyIndex, 1)
}
})
if (diffs[section].length === 0) delete diffs[section]
})
if (sensitiveKeysChanged > 0) {
diffs['Other sensitive keys changed'] = [`${sensitiveKeysChanged} key(s)`]
}
return diffs;
}
Then, in the main
function, we can pipe the JSON from performDiffs
to have additional keys hidden:
const diffs = hideKeys(performDiff(baseBranchFile, currentBranchFile));
Now, the diffs
output will look something like this:
::set-output name=diffs::{"Modified":["MY_API_URL"],"Other sensitive keys changed":["2 key(s)"]}
These keys can be supplied as a repository secret to the command as well, so the keys won't ever need to be displayed!
The workflow in action
Here is an example of a pull request that edits the file and executes the workflow:
And here is another pull request that does not edit the file, so the workflow is not run!
Conclusion
- GitHub Actions can run workflows against changed filesets in a pull request. See here.
- We can compare encrypted files by decrypting them in a workflow, using a JS script to diff them, and then output a list of Additions, Deletions, and Modifications as a comment on the pull request. Additional sensitive data to hide can be specified using repository secrets!
- This can work for other files, like flat JSON file structures, with some modifications.