diff --git a/.github/actions/test-assertions/action.yml b/.github/actions/test-assertions/action.yml new file mode 100644 index 0000000..1908dea --- /dev/null +++ b/.github/actions/test-assertions/action.yml @@ -0,0 +1,51 @@ +name: Common test assertions +description: Provides common test assertions for all tests +# TBH this also has side-effects, could do with a different name. + +inputs: + test_config_repo: + description: The GitHub config repo for use with the test. + type: string + required: true + test_config_repo_token: + description: A token with write access to the test config repo. + type: string + required: true + test_config_repo_branch: + description: The main branch on the config repository. + type: string + required: true + test_site_repo_config_branch: + description: The branch on the site repository that mirrors the config repository. + type: string + required: true + test_site_repo: + description: The GitHub site repo for use with the test. + type: string + required: true + test_site_repo_token: + description: A token with write access to the test site repo. + type: string + required: true + +runs: + using: composite + steps: + - uses: ./test/update-config/.github/actions/test-checkout + with: + test_config_repo: ${{ inputs.test_config_repo }} + test_config_repo_token: ${{ inputs.test_config_repo_token }} + test_site_repo: ${{ inputs.test_site_repo }} + test_site_repo_token: ${{ inputs.test_site_repo_token }} + + - name: Assert the state after running the action + shell: sh + working-directory: test + run: | + # Assert the state after running the action + set -eu + + if [[ "$(git -C remote rev-parse origin/${{ inputs.test_config_repo_branch }})" != "$(git -C site rev-parse origin/${{ inputs.test_site_repo_config_branch }})" ]]; then + echo "**TEST FAILURE:** The Drupal site repo's config-only branch wasn't in sync with the config repo's main branch." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi diff --git a/.github/actions/test-checkout/action.yml b/.github/actions/test-checkout/action.yml new file mode 100644 index 0000000..aa5713f --- /dev/null +++ b/.github/actions/test-checkout/action.yml @@ -0,0 +1,37 @@ +name: Checkout repos +description: Checkout the test site and config repos + +inputs: + test_config_repo: + description: The GitHub config repo for use with the test. + type: string + required: true + test_config_repo_token: + description: A token with write access to the test config repo. + type: string + required: true + test_site_repo: + description: The GitHub site repo for use with the test. + type: string + required: true + test_site_repo_token: + description: A token with write access to the test site repo. + type: string + required: true + +runs: + using: composite + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.test_site_repo }} + token: ${{ inputs.test_site_repo_token }} + path: test/site + fetch-depth: 0 + + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.test_config_repo }} + token: ${{ inputs.test_config_repo_token }} + path: test/remote + fetch-depth: 0 diff --git a/.github/actions/test-setup/action.yml b/.github/actions/test-setup/action.yml new file mode 100644 index 0000000..754e55d --- /dev/null +++ b/.github/actions/test-setup/action.yml @@ -0,0 +1,93 @@ +name: Setup for test +description: Prepares test repos for automated testing. + +inputs: + test_config_repo: + description: The GitHub config repo for use with the test. + type: string + required: true + test_config_repo_token: + description: A token with write access to the test config repo. + type: string + required: true + test_config_repo_branch: + description: The main branch on the config repository. + type: string + required: true + test_site_repo: + description: The GitHub site repo for use with the test. + type: string + required: true + test_site_repo_token: + description: A token with write access to the test site repo. + type: string + required: true + test_site_repo_config_branch: + description: The branch on the site repository that mirrors the config repository. + type: string + required: true + test_site_repo_pr_branch: + description: The name of the topic branch to create on the site repository with config changes. + type: string + required: true + test_site_repo_pr_branch_base: + description: The branch on the site repository from which to create the PR branch. + type: string + required: true + +# DO I WANT? + branch_prefix: + description: The prefix used for test branches. + type: string + required: true + +runs: + using: composite + steps: + + - uses: ./test/update-config/.github/actions/test-checkout + with: + test_config_repo: ${{ inputs.test_config_repo }} + test_config_repo_token: ${{ inputs.test_config_repo_token }} + test_site_repo: ${{ inputs.test_site_repo }} + test_site_repo_token: ${{ inputs.test_site_repo_token }} + + - name: Setup for test + shell: bash + working-directory: test + run: | + # Setup for test + set -eu + git config --global user.name 'Test User' + git config --global user.email 'test@example.com' + + pushd remote + git checkout --orphan ${{ inputs.test_config_repo_branch }}-new + git rm -rf . + touch c d + git add . + git commit -m "Add initial mock config" + echo modified > c + touch e + git add c e + git rm d + git commit -m "Updated" + git push origin HEAD:${{ inputs.test_config_repo_branch }} --force + git checkout ${{ inputs.test_config_repo_branch }} + git reset --hard origin/${{ inputs.test_config_repo_branch }} + popd + + pushd site + git checkout --orphan ${{ inputs.test_site_repo_pr_branch_base }}-new + git rm -rf . + mkdir -p a z config/sync + cp -r ../remote/* config/sync + touch {a,z}/.gitkeep b + git config user.name 'Test User' + git config user.email 'test@example.com' + git add . + git commit -m "Add mock Drupal site" + git push origin HEAD:${{ inputs.test_site_repo_pr_branch_base }} --force + git checkout ${{ inputs.test_site_repo_pr_branch_base }} + git reset --hard origin/${{ inputs.test_site_repo_pr_branch_base }} + popd diff --git a/.github/actions/test-teardown/action.yml b/.github/actions/test-teardown/action.yml new file mode 100644 index 0000000..b56ab1a --- /dev/null +++ b/.github/actions/test-teardown/action.yml @@ -0,0 +1,21 @@ +name: Tear down for test +description: Makes any necessary changes to clean up after running a test + +inputs: + branch_prefix: + description: The prefix used for test branches. + type: string + required: true + +runs: + using: composite + steps: + - name: Tear down for test + shell: bash + working-directory: test/site + run: | + # Tear down for test + set -eu + git for-each-ref "refs/remotes/origin/${{ inputs.branch_prefix }}*" --format '%(refname:short)' | while read refname + do git push -d origin ${refname//'origin/'/} + done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8f211a7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,384 @@ +name: Test +# THIS IS FOR TESTING THE ACTION: DO NOT USE IT IN YOUR DRUPAL PROJECT, USE +# workflow-templates/update-config-branch.yml instead. +# +# Secrets +# - TEST_CONFIG_REPO_TOKEN: A token with write access to the test config repo. +# - TEST_SITE_REPO_TOKEN: A token with write access to the test site repo. +# Variables +# - TEST_CONFIG_REPO: The GitHub config repo for use with the test. +# - TEST_SITE_REPO: The GitHub site repo for use with the test. + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + schedule: + # * is a special character in YAML so you have to quote this string + - cron: '0 0 1 * *' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-initial-setup: +# if: false + name: Test mirroring the config repo to the site repo (ie initial setup) + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-job + env: + test_config_repo_branch: "${{ github.job }}-main" + test_site_repo_config_branch: "${{ github.job }}-config-only" + test_site_repo_pr_branch: "${{ github.job }}-automatic-config-export" + test_site_repo_pr_branch_base: "${{ github.job }}-staging" + steps: + + - uses: actions/checkout@v4 + with: + path: test/update-config + + - uses: ./test/update-config/.github/actions/test-setup + with: + branch_prefix: "${{ github.job }}-" + test_config_repo: ${{ vars.TEST_CONFIG_REPO }} + test_config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + test_config_repo_branch: ${{ env.test_config_repo_branch }} + test_site_repo: ${{ vars.TEST_SITE_REPO }} + test_site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + test_site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + test_site_repo_pr_branch: ${{ env.test_site_repo_pr_branch }} + test_site_repo_pr_branch_base: ${{ env.test_site_repo_pr_branch_base }} + + - uses: ./test/update-config + with: + config_repo: ${{ vars.TEST_CONFIG_REPO }} + config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + config_repo_branch: ${{ env.test_config_repo_branch }} + site_repo: ${{ vars.TEST_SITE_REPO }} + site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + site_repo_pr_branch: ${{ env.test_site_repo_pr_branch }} + site_repo_pr_branch_base: ${{ env.test_site_repo_pr_branch_base }} + + - uses: ./test/update-config/.github/actions/test-assertions + with: + test_config_repo: ${{ vars.TEST_CONFIG_REPO }} + test_config_repo_branch: ${{ env.test_config_repo_branch }} + test_config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + test_site_repo: ${{ vars.TEST_SITE_REPO }} + test_site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + test_site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + + - name: Assert the state after running the action + env: + GH_TOKEN: ${{ secrets.TEST_SITE_REPO_TOKEN }} + working-directory: test/site + run: | + # Assert the state after running the action + set -eu + git checkout ${{ env.test_site_repo_pr_branch_base }} + if [[ "$(gh pr list --json id)" != "[]" ]]; then + echo "**TEST FAILURE:** There shouldn't be a PR after running the action the first time to create the config-only branch." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - uses: ./test/update-config/.github/actions/test-teardown + if: ${{ always() }} + with: + branch_prefix: "${{ github.job }}-" + + test-create-pr: +# if: false + name: Test updating the mirrored config branch and creating a PR from it. + runs-on: ubuntu-latest + env: + test_config_repo_branch: "${{ github.job }}-main" + test_site_repo_config_branch: "${{ github.job }}-config-only" + test_site_repo_pr_branch: "${{ github.job }}-automatic-config-export" + test_site_repo_pr_branch_base: "${{ github.job }}-staging" + steps: + + - uses: actions/checkout@v4 + with: + path: test/update-config + + - uses: ./test/update-config/.github/actions/test-setup + with: + branch_prefix: "${{ github.job }}-" + test_config_repo: ${{ vars.TEST_CONFIG_REPO }} + test_config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + test_config_repo_branch: ${{ env.test_config_repo_branch }} + test_site_repo: ${{ vars.TEST_SITE_REPO }} + test_site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + test_site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + test_site_repo_pr_branch: ${{ env.test_site_repo_pr_branch }} + test_site_repo_pr_branch_base: ${{ env.test_site_repo_pr_branch_base }} + + - name: 'Test setup for testing creating a PR' + working-directory: test + run: | + set -eu + cd site + git remote add remote-config ../remote + git fetch remote-config + git checkout -b ${{ env.test_site_repo_config_branch }} remote-config/${{ env.test_config_repo_branch }}^ + tmp_dir=$(mktemp -d) + cp * "$tmp_dir" + git push origin HEAD + + git checkout ${{ env.test_site_repo_pr_branch_base }} + rm config/sync/* + cp -r "$tmp_dir"/* config/sync + git add . + git commit --amend -m "Update" + git push origin HEAD --force + + - uses: ./test/update-config + with: + config_repo: ${{ vars.TEST_CONFIG_REPO }} + config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + config_repo_branch: ${{ env.test_config_repo_branch }} + site_repo: ${{ vars.TEST_SITE_REPO }} + site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + site_repo_pr_branch: ${{ env.test_site_repo_pr_branch }} + site_repo_pr_branch_base: ${{ env.test_site_repo_pr_branch_base }} + + - uses: ./test/update-config/.github/actions/test-assertions + with: + test_config_repo: ${{ vars.TEST_CONFIG_REPO }} + test_config_repo_branch: ${{ env.test_config_repo_branch }} + test_config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + test_site_repo: ${{ vars.TEST_SITE_REPO }} + test_site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + test_site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + + - name: Assert the state after running the action + env: + GH_TOKEN: ${{ secrets.TEST_SITE_REPO_TOKEN }} + working-directory: test/site + run: | + set -eu + exit=0 + tmp_dir=$(mktemp -d) + git checkout ${{ env.test_site_repo_config_branch }} + cp * "$tmp_dir" + + git checkout ${{ env.test_site_repo_pr_branch }} + if [[ "$(gh pr list --json id)" == "[]" ]]; then + echo "**TEST FAILURE:** There should be a PR created." >> "$GITHUB_STEP_SUMMARY" + exit=1 + fi + + rm config/sync/* + mv "$tmp_dir"/* config/sync + if [[ -n "$(git status --porcelain)" ]]; then + echo "**TEST FAILURE:** The PR branch config should match the config repo branch." >> "$GITHUB_STEP_SUMMARY" + exit=1 + fi + + rm -rf "$tmp_dir" + + exit $exit + + - uses: ./test/update-config/.github/actions/test-teardown + if: ${{ always() }} + with: + branch_prefix: "${{ github.job }}-" + + test-existing-pr: +# if: false + name: Test updating an existing PR. + runs-on: ubuntu-latest + env: + test_config_repo_branch: "${{ github.job }}-main" + test_site_repo_config_branch: "${{ github.job }}-config-only" + test_site_repo_pr_branch: "${{ github.job }}-automatic-config-export" + test_site_repo_pr_branch_base: "${{ github.job }}-staging" + steps: + + - uses: actions/checkout@v4 + with: + path: test/update-config + + - uses: ./test/update-config/.github/actions/test-setup + with: + branch_prefix: "${{ github.job }}-" + test_config_repo: ${{ vars.TEST_CONFIG_REPO }} + test_config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + test_config_repo_branch: ${{ env.test_config_repo_branch }} + test_site_repo: ${{ vars.TEST_SITE_REPO }} + test_site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + test_site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + test_site_repo_pr_branch: ${{ env.test_site_repo_pr_branch }} + test_site_repo_pr_branch_base: ${{ env.test_site_repo_pr_branch_base }} + + - name: 'Test setup for testing updating an existing PR' + env: + GH_TOKEN: ${{ secrets.TEST_SITE_REPO_TOKEN }} + working-directory: test + run: | + set -eu + cd site + # Ensure the site repo config branch is behind the config repo. + git remote add remote-config ../remote + git fetch remote-config + git checkout -b ${{ env.test_site_repo_config_branch }} remote-config/${{ env.test_config_repo_branch }}^ + tmp_dir=$(mktemp -d) + cp * "$tmp_dir" + git push origin HEAD + + # Set up an existing PR. + git checkout ${{ env.test_site_repo_pr_branch_base }} + rm config/sync/* + touch config/sync/test-existing-pr + git add config/sync + git commit --amend -m "Initial commmit" + git push origin HEAD --force + + git checkout -b ${{ env.test_site_repo_pr_branch }} + cp -r "$tmp_dir"/* config/sync + git add config/sync + git commit -m "Update" + git push origin HEAD + + gh pr create --base ${{ env.test_site_repo_pr_branch_base }} --title "Test PR" --body "Test PR" + + rm -rf "$tmp_dir" + + - uses: ./test/update-config + with: + config_repo: ${{ vars.TEST_CONFIG_REPO }} + config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + config_repo_branch: ${{ env.test_config_repo_branch }} + site_repo: ${{ vars.TEST_SITE_REPO }} + site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + site_repo_pr_branch: ${{ env.test_site_repo_pr_branch }} + site_repo_pr_branch_base: ${{ env.test_site_repo_pr_branch_base }} + + - uses: ./test/update-config/.github/actions/test-assertions + with: + test_config_repo: ${{ vars.TEST_CONFIG_REPO }} + test_config_repo_branch: ${{ env.test_config_repo_branch }} + test_config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + test_site_repo: ${{ vars.TEST_SITE_REPO }} + test_site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + test_site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + + - name: Assert the state after running the action + env: + GH_TOKEN: ${{ secrets.TEST_SITE_REPO_TOKEN }} + working-directory: test/site + run: | + # Assert the state after running the action + set -eu + if [[ "$(gh pr list -H ${{ env.test_site_repo_pr_branch }} --json id)" == "[]" ]]; then + echo "**TEST FAILURE:** There should still be an existing PR." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - uses: ./test/update-config/.github/actions/test-teardown + if: ${{ always() }} + with: + branch_prefix: "${{ github.job }}-" + + test-identical-config: +# if: false + name: Test updating the mirrored config branch only to find it matches what's already in staging. + runs-on: ubuntu-latest + env: + test_config_repo_branch: "${{ github.job }}-main" + test_site_repo_config_branch: "${{ github.job }}-config-only" + test_site_repo_pr_branch: "${{ github.job }}-automatic-config-export" + test_site_repo_pr_branch_base: "${{ github.job }}-staging" + steps: + + - uses: actions/checkout@v4 + with: + path: test/update-config + + - uses: ./test/update-config/.github/actions/test-setup + with: + branch_prefix: "${{ github.job }}-" + test_config_repo: ${{ vars.TEST_CONFIG_REPO }} + test_config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + test_config_repo_branch: ${{ env.test_config_repo_branch }} + test_site_repo: ${{ vars.TEST_SITE_REPO }} + test_site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + test_site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + test_site_repo_pr_branch: ${{ env.test_site_repo_pr_branch }} + test_site_repo_pr_branch_base: ${{ env.test_site_repo_pr_branch_base }} + + - name: 'Test setup for testing matching config' + working-directory: test + run: | + set -eu + cd site + git remote add remote-config ../remote + git fetch remote-config + git checkout -b ${{ env.test_site_repo_config_branch }} remote-config/${{ env.test_config_repo_branch }}^ + tmp_dir=$(mktemp -d) + cp * "$tmp_dir" + git push origin HEAD + + - uses: ./test/update-config + with: + config_repo: ${{ vars.TEST_CONFIG_REPO }} + config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + config_repo_branch: ${{ env.test_config_repo_branch }} + site_repo: ${{ vars.TEST_SITE_REPO }} + site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + site_repo_pr_branch: ${{ env.test_site_repo_pr_branch }} + site_repo_pr_branch_base: ${{ env.test_site_repo_pr_branch_base }} + + - uses: ./test/update-config/.github/actions/test-assertions + with: + test_config_repo: ${{ vars.TEST_CONFIG_REPO }} + test_config_repo_branch: ${{ env.test_config_repo_branch }} + test_config_repo_token: ${{ secrets.TEST_CONFIG_REPO_TOKEN }} + test_site_repo: ${{ vars.TEST_SITE_REPO }} + test_site_repo_token: ${{ secrets.TEST_SITE_REPO_TOKEN }} + test_site_repo_config_branch: ${{ env.test_site_repo_config_branch }} + + - name: Assert the state after running the action + env: + GH_TOKEN: ${{ secrets.TEST_SITE_REPO_TOKEN }} + working-directory: test/site + run: | + set -eu + exit=0 + tmp_dir=$(mktemp -d) + git checkout ${{ env.test_site_repo_config_branch }} + cp * "$tmp_dir" + + if [[ "$(gh pr list --head ${{ env.test_site_repo_pr_branch }} --json id)" != "[]" ]]; then + echo "**TEST FAILURE:** There should not be a PR created." >> "$GITHUB_STEP_SUMMARY" + exit=1 + fi + + git checkout ${{ env.test_site_repo_pr_branch_base }} + rm config/sync/* + mv "$tmp_dir"/* config/sync + if [[ -n "$(git status --porcelain)" ]]; then + echo "**TEST FAILURE:** The site staging branch should match the config repo branch." >> "$GITHUB_STEP_SUMMARY" + exit=1 + fi + + rm -rf "$tmp_dir" + + exit $exit + + - uses: ./test/update-config/.github/actions/test-teardown + if: ${{ always() }} + with: + branch_prefix: "${{ github.job }}-" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3c286d --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Automatic configuration export + +![Current test results](https://github.com/andriokha/update-config/actions/workflows/test.yml/badge.svg) + +**TODO: Extract the Drupal module `config_change_track` from Subscriptions.** + +This project supports automatically exporting configuration from a Drupal site +and creating a PR with the changes. + +## Goals + +1. Don't remotely access the host: ideally we avoid adding a token with SSH + access to the production environment to our CI. (And in many cases we only + have a shared account with access to multiple sites, which makes it worse.) +2. Ensure the GitHub token deployed on the Drupal site can't push to the source + repo: if it got exfiltrated it could be used to add code without review and + make deployments. + +## Overview + +1. Use an intermediate configuration (only) repo. +2. Give the Drupal site write access to the repo and have it regularly update a + branch with the current config. +3. Give the Drupal site source repo read access to the config repo and have it + regularly check for updates, creating a PR when found. + +## Setup + +1. Create an intermediate repo that the Drupal site will push config to and the + Drupal site repo will poll for changes. Eg. + ```shell + # Assuming the config repo URL is in $config_repo_url. + # Run from the project root. + config_dir=config/sync + config_repo_dir="$(mktemp -d)" + cp "$config_dir"/* "$config_repo_dir" + pushd "$config_repo_dir" + git init + git remote add origin "$config_repo_url" + git push origin HEAD + popd + rm -rf "$config_repo_dir" + ``` +2. Create two access tokens for the config repo: + 1. A write token for the Drupal site to push config changes; + 2. A read token for the Drupal site repo to pull config changes. +3. Set up the host to push to the config repo: + 1. Add the required environment variables, see [`check-and-push-config.sh`]. + 2. Add _Config Change Track_ to the codebase and enable. **TODO: This needs + extracting from Faith Subscriptions.** + 3. Schedule [`check-and-push-config.sh`] to run regularly. +4. Set up the Drupal site repo to pull from the config repo: + 1. Check [`update-config-branch.yml`] for required permissions, secrets and + variables to set up. + 2. Add [`update-config-branch.yml`] to the Drupal site repo's `/.github` + directory. It's configured to check for changes every 30 minutes (though + GitHub doesn't guarantee it will run that frequently). It will create a + branch `config-only` that mirrors the `main` branch of the config repo and + keep it up-to-date. When there are changes a PR is created against + `staging`. + +## Action Usage + +The action is responsible for keeping the site repo's config branch up-to-date +with the config repo, and opening a PR when the latest config doesn't match +what's in the site repo's staging branch. + +```yaml +uses: andriokha/update-config@main +with: + # The GitHub config repo, eg. MyOrg/MySiteConfig. + config_repo: '' + + # A token with read access to the config repository. + config_repo_token: '' + + # The branch on the config repository. + config_repo_branch: '' + + # The GitHub Drupal site repo, eg. MyOrg/MySite. + # Default: ${{ github.repository }} + site_repo: '' + + # The GitHub Drupal site repo token with access to write contents and create + # PRs. + # Default: ${{ github.token }} + site_repo_token: '' + + # The branch on the site repository that mirrors the config repository. + # Default: config-only + site_repo_config_branch: '' + + # The name of the topic branch to create on the site repository with config + # changes. + # Default: automatic-config-export + site_repo_pr_branch: '' + + # The branch on the site repository from which to create the PR branch. + # Default: staging + site_repo_pr_branch_base: '' + + # The name used on the git merge commit. + # Default: R2D2 + committer_name: '' + + # The email used on the git merge commit. + # Default: update-config@example.com + committer_email: '' + + # A space-separated list of github users including '@' to ping on a new PR, + # eg. '@alice @bob'. + # Default: '' + github_notify: '' +``` + +[`check-and-push-config.sh`]: scripts/check-and-push-config.sh +[`update-config-branch.yml`]: workflow-templates/update-config-branch.yml diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..d2631c7 --- /dev/null +++ b/action.yml @@ -0,0 +1,198 @@ +name: Update config branch +description: Updates the config-only branch and creates a PR if there are any changes. +# This works as part of automating config export. A cron job on Platform.sh +# periodically updates an intermediate config repo with the latest config. The +# config repo only contains config, it doesn't share a history with the site +# repo. This workflow keeps a branch on the site repo up-to-date with a branch +# on the config repo. Whenever the local config-only branch gets updated, the +# workflow creates a PR. If a PR already exists, the branch will just be +# updated. One or more users can be notified. +# +# Workflow permissions: +# The repo calling this must be configured to support GitHub actions making PRs. + +inputs: + config_repo: + description: The GitHub config repo, eg. MyOrg/MySiteConfig. + type: string + required: true + config_repo_token: + description: A token with read access to the config repository. + type: string + required: true + config_repo_branch: + description: The branch on the config repository. + type: string + required: true + default: main + site_repo: + description: The GitHub Drupal site repo, eg. MyOrg/MySite + type: string + required: true + default: ${{ github.repository }} + site_repo_token: + description: The GitHub Drupal site repo token with access to write contents and create PRs. + type: string + required: true + default: ${{ github.token }} + site_repo_config_branch: + description: The branch on the site repository that mirrors the config repository. + type: string + required: true + default: config-only + site_repo_pr_branch: + description: The name of the topic branch to create on the site repository with config changes. + type: string + required: true + default: automatic-config-export + site_repo_pr_branch_base: + description: The branch on the site repository from which to create the PR branch. + type: string + required: true + default: staging + committer_name: + description: The name used on the git merge commit. + type: string + required: true + default: R2D2 + committer_email: + description: The email used on the git merge commit. + type: string + required: true + default: update-config@example.com + github_notify: + description: A space-separated list of github users including '@' to ping on a new PR, eg. '@andy @becca'. + type: string + +runs: + using: composite + steps: + # Optimize for the normal case that the two branches are the same. So start + # by doing a shallow checkout of both and checking their hashes. If they're + # different, then do a full checkout to enable a merge. + - uses: actions/checkout@v4 + id: checkout_site_repo_config_branch + with: + repository: ${{ inputs.site_repo }} + ref: ${{ inputs.site_repo_config_branch }} + token: ${{ inputs.site_repo_token }} + path: local + # It might error if the config-only branch doesn't exist yet. + continue-on-error: true + + - uses: actions/checkout@v4 + if: ${{ steps.checkout_site_repo_config_branch.outcome == 'failure' }} + with: + repository: ${{ inputs.site_repo }} + token: ${{ inputs.site_repo_token }} + path: local + + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.config_repo }} + ref: ${{ inputs.config_repo_branch }} + token: ${{ inputs.config_repo_token }} + # If we failed to checkout the site repo config branch, we'll need a + # full checkout to set it up. + fetch-depth: ${{ steps.checkout_site_repo_config_branch.outcome != 'failure' && '1' || '0' }} + path: remote + + - name: Create the site repository config-only branch if necessary. + if: ${{ steps.checkout_site_repo_config_branch.outcome == 'failure' }} + shell: sh + run: | + # Create the site repository config-only branch if necessary. + set -eu + cd local + git remote add remote-config ../remote + git fetch remote-config + git checkout -b ${{ inputs.site_repo_config_branch }} remote-config/${{ inputs.config_repo_branch }} + git push origin HEAD + git remote remove remote-config + echo 'Created the branch `${{ inputs.site_repo_config_branch }}` to mirror `${{ inputs.config_repo }}:${{ inputs.config_repo_branch }}`.' >> $GITHUB_STEP_SUMMARY + + - name: Read HEAD from new branches + shell: sh + run: | + # Read HEAD from new branches + set -eu + local_head=$(git -C local rev-parse HEAD) + remote_head=$(git -C remote rev-parse HEAD) + echo "local_head=$local_head" >> "$GITHUB_ENV" + echo "remote_head=$remote_head" >> "$GITHUB_ENV" + if [ "$local_head" = "$remote_head" ]; then + echo 'The local config branch `${{ inputs.site_repo_config_branch }}` is up-to-date with the remote. No further action required.' >> $GITHUB_STEP_SUMMARY + fi + + - uses: actions/checkout@v4 + if: ${{ env.local_head != env.remote_head }} + with: + repository: ${{ inputs.site_repo }} + ref: ${{ inputs.site_repo_config_branch }} + token: ${{ inputs.site_repo_token }} + fetch-depth: 0 + path: local + + - uses: actions/checkout@v4 + # If the repo config branch was created then we already have a full + # checkout. + if: ${{ env.local_head != env.remote_head && steps.checkout_site_repo_config_branch.outcome != 'failure' }} + with: + repository: ${{ inputs.config_repo }} + ref: ${{ inputs.config_repo_branch }} + path: remote + token: ${{ inputs.config_repo_token }} + fetch-depth: 0 + + - name: Update the local config-only branch from the remote + if: ${{ env.local_head != env.remote_head }} + working-directory: local + shell: sh + run: | + # Update the local config-only branch from the remote + set -eu + git remote add remote-config ../remote + git fetch remote-config + git reset --hard remote-config/${{ inputs.config_repo_branch }} + git push origin HEAD + + - name: Create a site branch with the config and open a PR + if: ${{ env.local_head != env.remote_head }} + working-directory: local + env: + GH_TOKEN: ${{ inputs.site_repo_token }} + shell: bash + run: | + # Create a site branch with the config and open a PR + set -eu + source_branch=${{ inputs.site_repo_pr_branch }} + if ! git show-branch remotes/origin/${{ inputs.site_repo_pr_branch }} &> /dev/null; then + git branch ${{ inputs.site_repo_pr_branch }} origin/${{ inputs.site_repo_pr_branch_base }} + source_branch=${{ inputs.site_repo_pr_branch_base }} + fi + git checkout ${{ inputs.site_repo_pr_branch }} + + cd config/sync + rm * + mv "$GITHUB_WORKSPACE"/remote/* . + git add . + if [[ -n "$(git status --porcelain)" ]]; then + git config user.name '${{ inputs.committer_name }}' + git config user.email '${{ inputs.committer_email }}' + git commit -m "Export config from Prod" -m "${{ inputs.config_repo }}:$remote_head" + git push origin HEAD + echo 'Updated the config in branch `${{ inputs.site_repo_config_branch }}`.' >> $GITHUB_STEP_SUMMARY + # Create a PR if there isn't already one. + if [[ "$(gh pr list --head ${{ inputs.site_repo_pr_branch }} --json id)" == "[]" ]]; then + pr_url=$(gh pr create \ + -B ${{ inputs.site_repo_pr_branch_base }} \ + -H ${{ inputs.site_repo_pr_branch }} \ + --title 'Merge latest config export into ${{ inputs.site_repo_pr_branch_base }}' \ + --body 'PR created automatically by update-config. ${{ inputs.github_notify }}') + echo "[Created a PR]($pr_url) to merge the changes back into \`${{ inputs.site_repo_pr_branch_base }}\`. " >> $GITHUB_STEP_SUMMARY + else + echo "A PR for the branch already exists. No further action required." >> $GITHUB_STEP_SUMMARY + fi + else + echo "Branch \`${{ inputs.site_repo_config_branch }}\` has been updated, but the changes match what's already in branch \`$source_branch\`. No further action required." >> $GITHUB_STEP_SUMMARY + fi diff --git a/scripts/check-and-push-config.sh b/scripts/check-and-push-config.sh new file mode 100755 index 0000000..aee217d --- /dev/null +++ b/scripts/check-and-push-config.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Exports and pushes config to the intermediate repo, causing a PR to be +# created. +# +# Required environment variables: +# - CONFIG_REPO_URL: The config repo URL. +# Optional environment variables: +# - CONFIG_REPO_BRANCH: The config repo branch. +# - UPDATE_CONFIG_GIT_EMAIL: The email address to use for the commit. +# - UPDATE_CONFIG_GIT_NAME: The name to use for the commit. +# - UPDATE_CONFIG_GIT_MESSAGE: The message for the git commit. +# +# Note the repo's access token can be passed with the repo URL, eg. +# https://ABC123:@github.com/MyOrg/MySiteConfig + +set -eu + +commit_message="Export config from Prod" +config_repo_branch=${CONFIG_REPO_BRANCH:-main} + +# Check if config needs to be exported +if [[ $(drush config-change-track:needs-export) == "0" ]]; then + exit # No changes to export so early out. +fi + +temp_dir=${CONFIG_REPO_TEMP_DIR-/tmp/config_change_track} +mkdir -p "$temp_dir" +pushd "$temp_dir" +if [[ -d .git ]]; then + # Shouldn't really be necessary, but just in case. + git fetch + git reset --hard origin/$config_repo_branch +else + git clone --branch $config_repo_branch "$CONFIG_REPO_URL" . +fi +time=$(date '+%s') +# Note if using config_split, this will only work with 2.x and collection +# storage. +# See https://www.drupal.org/node/3001485#comment-14474479 +# (An alternative solution would be to modify $settings['config_sync_director'] +# just for this command.) +drush config:export --destination="$temp_dir" --yes +git add . +git config user.name "${UPDATE_CONFIG_GIT_NAME:-R2D2}" +git config user.email "${UPDATE_CONFIG_GIT_EMAIL:-config-update@example.com}" +# Allow for the possibility that there are no changes. +if [[ -n "$(git status --porcelain)" ]]; then + git commit -m "${UPDATE_CONFIG_GIT_MESSAGE:-Export config from Prod}" + git push +fi +drush config-change-track:set-last-export --time $time + +popd diff --git a/workflow-templates/update-config-branch.yml b/workflow-templates/update-config-branch.yml new file mode 100644 index 0000000..4045c6c --- /dev/null +++ b/workflow-templates/update-config-branch.yml @@ -0,0 +1,40 @@ +name: Check for config updates + +# Calls the update-config action. +# +# Note that GitHub's scheduled tasks aren't guaranteed to run when they're +# scheduled; if you want that guarantee they should be triggered externally. +# +# Workflow permissions: The repo must be configured to support GitHub actions +# making PRs. +# Secrets: +# - CONFIG_REPO_TOKEN: The config repo token with content read access. +# Variables: +# - CONFIG_REPO: The GitHub config repo, eg. MyOrg/MySiteConfig. +# - SITE_REPO_PR_BRANCH_BASE: (optional) The branch from which the config topic +# branch will be made for the PR; defaults to 'staging'. +# - GITHUB_NOTIFY: (optional) A space-separated list of github users including +# '@' to ping on a new PR, eg. '@andy @becca'. + +on: + workflow_dispatch: + schedule: + # * is a special character in YAML so you have to quote this string + - cron: '*/30 * * * *' + +concurrency: + group: update-config-branch + +jobs: + update: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: andriokha/update-config@main + with: + config_repo: ${{ vars.CONFIG_REPO }} + config_repo_token: ${{ secrets.CONFIG_REPO_TOKEN }} + github_notify: ${{ vars.GITHUB_NOTIFY }} + site_repo_pr_branch_base: ${{ vars.SITE_REPO_PR_BRANCH_BASE || 'staging' }}