From 693796008b0447057c020fdd04762f052e18df65 Mon Sep 17 00:00:00 2001 From: Bart Date: Fri, 24 Jul 2026 11:10:56 -0400 Subject: [PATCH 1/9] feat: Add reusable workflow to sync branches and tags --- .github/scripts/sync-branches.sh | 178 ++++++++ .github/scripts/sync-common-functions.sh | 82 ++++ .github/scripts/sync-tags.sh | 145 +++++++ .github/workflows/sync-branches-tags.yml | 128 ++++++ .github/workflows/test-clone-repo.yml | 59 +++ .github/workflows/test-configure-git.yml | 45 ++ .../workflows/test-configure-github-app.yml | 10 +- .../workflows/test-encrypt-decrypt-token.yml | 84 ++++ .github/workflows/test-sync-branches-tags.yml | 389 ++++++++++++++++++ README.md | 7 + clone-repo/action.yml | 29 ++ configure-git/action.yml | 24 ++ configure-github-app/action.yml | 24 +- decrypt-token/action.yml | 33 ++ encrypt-token/action.yml | 28 ++ 15 files changed, 1244 insertions(+), 21 deletions(-) create mode 100755 .github/scripts/sync-branches.sh create mode 100755 .github/scripts/sync-common-functions.sh create mode 100755 .github/scripts/sync-tags.sh create mode 100644 .github/workflows/sync-branches-tags.yml create mode 100644 .github/workflows/test-clone-repo.yml create mode 100644 .github/workflows/test-configure-git.yml create mode 100644 .github/workflows/test-encrypt-decrypt-token.yml create mode 100644 .github/workflows/test-sync-branches-tags.yml create mode 100644 clone-repo/action.yml create mode 100644 configure-git/action.yml create mode 100644 decrypt-token/action.yml create mode 100644 encrypt-token/action.yml diff --git a/.github/scripts/sync-branches.sh b/.github/scripts/sync-branches.sh new file mode 100755 index 0000000..8b708b0 --- /dev/null +++ b/.github/scripts/sync-branches.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# +# Synchronize important branches from a source repository into a destination repository. Missing +# branches are always created. By default branches are fast-forwarded only, while diverged branches +# are skipped and cause a non-zero exit. Force mode overwrites diverged branches with a force-push +# (using --force-with-lease). Dry-run mode reports what would change without pushing anything. +# +# Configuration is supplied via environment variables: +# +# Required: +# GH_TOKEN Token with read access to the source repository and write access to the +# destination. A public repository ignores the token as long as it is valid. When +# running this script locally, your personal access token must have at least the +# "repo", "read:org" (for branches in organization repos), and "workflow" scopes. +# SOURCE_REPO Source repository, as "owner/repo" (github.com is assumed). +# DEST_REPO Destination repository, as "owner/repo" (github.com is assumed). +# BRANCH A single branch name or glob to synchronize. A glob may match several branches, +# all of which are synchronized. +# +# Optional: +# DRY_RUN "true" to report changes without pushing. +# (default: false) +# FORCE "true" to overwrite diverged destination branches. +# (default: false) +# WORKDIR Directory to initialize the working repo in. +# (default: a fresh mktemp dir) +# +# Exit status: +# 0 All branches synchronized successfully (or a clean dry run was performed). +# 1 At least one branch could not be synchronized (diverged without FORCE, or a push was +# rejected because the destination changed concurrently); other branches were still +# attempted. +# 2 Invalid configuration / usage error. + +set -euo pipefail + +# shellcheck source=sync-common-functions.sh +source "${BASH_SOURCE[0]%/*}/sync-common-functions.sh" + +# --- Configuration ----------------------------------------------------------------------------- + +set_common_defaults +BRANCH="${BRANCH:-}" + +# Internal git ref/remote names used within the working repository. +BRANCH_REF="refs/heads" +SOURCE_REF="refs/remotes/source" +DEST_REF="refs/remotes/dest" +SOURCE_REMOTE="source" +DEST_REMOTE="dest" +REMOTE_REF_PREFIX="${BRANCH_REF}" + +# --- Helpers ----------------------------------------------------------------------------------- + +# Validate the configuration supplied via the environment, exiting with status 2 on any problem. +validate_config() { + validate_common_config + [ -n "${BRANCH}" ] || die "BRANCH must be a non-empty branch name or glob." 2 +} + +# Fetch the branch (or all branches matching the glob) from both remotes into local tracking refs. +# The '+' prefix force-updates the tracking refs so they always reflect each remote. +fetch_branches() { + # Unlike the destination, a missing source branch is a real error as there is nothing to + # synchronize from. Both "no matching refs" and other failures are fatal, but they get distinct + # messages to aid diagnosis, and both exit 2 to match the documented usage/config status rather + # than git's bare exit 1. The 'git ls-remote --exit-code' command returns 2 only when no ref + # matches, so we capture the status with '|| ls_status=$?' to ensure a non-zero exit does not + # trip 'set -e' before it is inspected. Any other non-zero status than 2 is treated as a real + # failure. + echo "Fetching source branch: ${BRANCH}" + if ! git fetch --quiet "${SOURCE_REMOTE}" "+${BRANCH_REF}/${BRANCH}:${SOURCE_REF}/${BRANCH}"; then + local ls_status=0 + git ls-remote --exit-code --heads "${SOURCE_REMOTE}" "${BRANCH_REF}/${BRANCH}" >/dev/null 2>&1 || ls_status=$? + if [ "${ls_status}" -eq 2 ]; then + die "Source branch(es) '${BRANCH}' not found in '${SOURCE_REPO}'." 2 + fi + die "Failed to fetch source branch(es) '${BRANCH}' from '${SOURCE_REPO}'." 2 + fi + + # A glob that matches no refs makes 'git fetch' succeed having fetched nothing (git does not + # treat an empty glob match as an error), so a missing or mistyped BRANCH would otherwise go + # undetected. + if [ -z "$(git for-each-ref "${SOURCE_REF}/")" ]; then + die "Source branch(es) '${BRANCH}' not found in '${SOURCE_REPO}'." 2 + fi + + # A literal (non-glob) branch that does not yet exist on the destination makes 'git fetch' fail; + # that case is tolerated here and the branch is created below. Any other failure (e.g. a + # transient network or remote error) must not be masked, or the branch would be wrongly treated + # as missing. + echo "Fetching destination branch: ${BRANCH}" + if ! git fetch --quiet "${DEST_REMOTE}" "+${BRANCH_REF}/${BRANCH}:${DEST_REF}/${BRANCH}"; then + local ls_status=0 + git ls-remote --exit-code --heads "${DEST_REMOTE}" "${BRANCH_REF}/${BRANCH}" >/dev/null 2>&1 || ls_status=$? + if [ "${ls_status}" -ne 2 ]; then + die "Failed to fetch destination branch(es) '${BRANCH}' from '${DEST_REPO}'." 2 + fi + fi +} + +# Synchronize a single already-fetched ref onto the destination. Returns 0 on success and 1 on a +# recoverable per-branch failure (so the caller can continue with other branches). +sync_ref() { + local ref="$1" + + # Create branches that do not yet exist on the destination. The refspec is not forced, so if a + # racing push created the branch between our fetch and now, the destination is rejected rather + # than clobbered; this is recorded as a failure and the next branch is still attempted. + if ! git show-ref --verify --quiet "${DEST_REF}/${ref}"; then + echo "Creating: ${ref}" + if ! push_ref "${ref}"; then + echo "::error::Branch '${ref}' could not be created (it may have been created concurrently); skipping." + return 1 + fi + return 0 + fi + + # Overwrite diverged branches when force is enabled. The --force-with-lease flag guards against + # clobbering destination commits that appeared after our fetch: if the destination advanced in + # the meantime the push is rejected; this is recorded as a failure and the next branch is still + # attempted. + if [ "${FORCE}" = "true" ]; then + echo "Overwriting: ${ref}" + if ! push_ref "${ref}" --force-with-lease; then + echo "::error::Branch '${ref}' could not be overwritten (the destination changed since fetch); skipping." + return 1 + fi + return 0 + fi + + # Check out the destination branch and attempt a fast-forward to the source. + echo "Syncing: ${ref}" + git checkout --quiet -B "${ref}" "${DEST_REF}/${ref}" + if ! git merge --ff-only "${SOURCE_REF}/${ref}"; then + echo "::error::Branch '${ref}' has diverged and cannot be fast-forwarded; skipping." + return 1 + fi + + if ! git push "${PUSH_OPTS[@]+"${PUSH_OPTS[@]}"}" "${DEST_REMOTE}" "HEAD:${BRANCH_REF}/${ref}"; then + echo "::error::Branch '${ref}' could not be pushed (the destination changed since fetch); skipping." + return 1 + fi +} + +# Synchronize every ref that was fetched under SOURCE_REF (a glob may match several). Returns 1 if +# any branch failed to synchronize, 0 otherwise. +sync_branches() { + # A dry run adds --dry-run to every push so nothing is actually written. The refspecs are + # expanded as "${PUSH_OPTS[@]+"${PUSH_OPTS[@]}"}" to avoid the "unbound variable" error that an + # empty array triggers under 'set -u' on older versions of Bash. + PUSH_OPTS=() + if [ "${DRY_RUN}" = "true" ]; then + echo "Dry run: no branches will be pushed." + PUSH_OPTS+=("--dry-run") + fi + + # Process substitution feeds the loop so it runs in the current shell and 'failed' persists. + local failed=0 + local ref + while IFS= read -r ref; do + [ -n "${ref}" ] || continue + sync_ref "${ref}" || failed=1 + done < <(git for-each-ref --format='%(refname:lstrip=3)' "${SOURCE_REF}/") + + return "${failed}" +} + +# --- Main -------------------------------------------------------------------------------------- + +main() { + validate_config + setup_working_repo + fetch_branches + sync_branches +} + +main "$@" diff --git a/.github/scripts/sync-common-functions.sh b/.github/scripts/sync-common-functions.sh new file mode 100755 index 0000000..b3d393a --- /dev/null +++ b/.github/scripts/sync-common-functions.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# Common helpers shared by sync-branches.sh and sync-tags.sh. Intended to be sourced, not executed +# directly. + +# Emit an error and exit with the given status (default 2 for usage/config errors). +die() { + local status="${2:-2}" + echo "::error::$1" >&2 + exit "${status}" +} + +# Apply the defaults shared by all sync scripts. These match what is set in the workflow file, so +# the scripts can also be run locally. Keep in sync with the workflow file. +set_common_defaults() { + DRY_RUN="${DRY_RUN:-false}" + FORCE="${FORCE:-false}" +} + +# Push the given source ref to the destination. Reads the SOURCE_REF and REMOTE_REF_PREFIX globals +# (the caller sets REMOTE_REF_PREFIX to "refs/heads" or "refs/tags") and the PUSH_OPTS global (e.g. +# --dry-run). Any extra arguments (e.g. --force-with-lease) are inserted before the refspec. +# Args: [git-push-option...] +push_ref() { + local ref="$1" + shift + git push "$@" "${PUSH_OPTS[@]+"${PUSH_OPTS[@]}"}" "${DEST_REMOTE}" \ + "${SOURCE_REF}/${ref}:${REMOTE_REF_PREFIX}/${ref}" +} + +# Add a remote pointing at a github.com repository and authenticate to it with GH_TOKEN. The token +# is passed via an HTTP Authorization header rather than embedded in the remote URL. Embedding would +# persist the credential in .git/config and, because git prints the remote URL in its error +# messages, risk leaking it into CI logs. Passing it as an 'extraheader' keeps git's output limited +# to 'https://github.com/'. This mirrors how actions/checkout authenticates. The 'tr -d' +# command strips any line breaks base64 may insert so the header stays on one line. A public +# repository ignores a valid token, so the same header is used for both. +# Args: +add_authenticated_remote() { + local remote="$1" repo="$2" url="https://github.com/${repo}" + git remote add "${remote}" "${url}" + git config --local "http.${url}/.extraheader" \ + "AUTHORIZATION: basic $(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n')" +} + +# Validate configuration shared by all sync scripts: GH_TOKEN, SOURCE_REPO, DEST_REPO, DRY_RUN, +# FORCE, and git presence. +validate_common_config() { + [ -n "${GH_TOKEN:-}" ] || die "GH_TOKEN is required but not set." 2 + [ -n "${SOURCE_REPO:-}" ] || die "SOURCE_REPO is required but not set." 2 + [ -n "${DEST_REPO:-}" ] || die "DEST_REPO is required but not set." 2 + + case "${DRY_RUN}" in + true | false) ;; + *) die "DRY_RUN must be 'true' or 'false', got '${DRY_RUN}'." 2 ;; + esac + + case "${FORCE}" in + true | false) ;; + *) die "FORCE must be 'true' or 'false', got '${FORCE}'." 2 ;; + esac + + command -v git >/dev/null 2>&1 || die "git is required but not found on PATH." 2 +} + +# Initialize the working repository and wire up the authenticated remotes. Reachability is not +# checked here, because if the first real fetch against each remote (in fetch_branches/fetch_tags) +# fails it will provide a clear message if the repository is unreachable or the token is invalid, so +# checking it here would be redundant. +setup_working_repo() { + if [ -z "${WORKDIR:-}" ]; then + WORKDIR="$(mktemp -d)" + trap 'rm -rf "${WORKDIR}"' EXIT + fi + mkdir -p "${WORKDIR}" + cd "${WORKDIR}" || exit + + git init --quiet + # Both remotes carry the token; a public source simply ignores it as long as the token is valid. + add_authenticated_remote "${SOURCE_REMOTE}" "${SOURCE_REPO}" + add_authenticated_remote "${DEST_REMOTE}" "${DEST_REPO}" +} diff --git a/.github/scripts/sync-tags.sh b/.github/scripts/sync-tags.sh new file mode 100755 index 0000000..b44280e --- /dev/null +++ b/.github/scripts/sync-tags.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# +# Synchronize tags from a source repository into a destination repository. Missing tags are always +# created. By default existing tags that match on both sides are a no-op; diverged tags (same name, +# different object) are skipped and cause a non-zero exit. Force mode overwrites diverged tags with +# a force-push (using --force-with-lease). Dry-run mode reports what would change without pushing +# anything. +# +# This script is intended to run after the branches have been synchronized (see sync-branches.sh), +# so that tagged commits are already reachable from a branch in the destination before their tags +# arrive. +# +# Configuration is supplied via environment variables: +# +# Required: +# GH_TOKEN Token with read access to the source repository and write access to the +# destination. A public repository ignores the token as long as it is valid. When +# running this script locally, your personal access token must have at least the +# "repo", "read:org" (for tags in organization repos), and "workflow" scopes. +# SOURCE_REPO Source repository, as "owner/repo" (github.com is assumed). +# DEST_REPO Destination repository, as "owner/repo" (github.com is assumed). +# +# Optional: +# DRY_RUN "true" to report changes without pushing. +# (default: false) +# FORCE "true" to overwrite diverged destination tags. +# (default: false) +# WORKDIR Directory to initialize the working repo in. +# (default: a fresh mktemp dir) +# +# Exit status: +# 0 All tags synchronized successfully (or a clean dry run was performed). +# 1 At least one tag could not be synchronized (diverged without FORCE, or a push was +# rejected because the destination changed concurrently); other tags were still attempted. +# 2 Invalid configuration / usage error. + +set -euo pipefail + +# shellcheck source=sync-common-functions.sh +source "${BASH_SOURCE[0]%/*}/sync-common-functions.sh" + +# --- Configuration ----------------------------------------------------------------------------- + +set_common_defaults + +# Internal git ref/remote names used within the working repository. +TAG_REF="refs/tags" +SOURCE_REF="refs/remotes/source-tags" +DEST_REF="refs/remotes/dest-tags" +SOURCE_REMOTE="source" +DEST_REMOTE="dest" +REMOTE_REF_PREFIX="${TAG_REF}" + +# --- Helpers ----------------------------------------------------------------------------------- + +# Validate the configuration supplied via the environment, exiting with status 2 on any problem. +validate_config() { + validate_common_config +} + +fetch_tags() { + echo "Fetching tags from source." + # --no-tags suppresses git's automatic tag-following, which would otherwise populate + # refs/tags/* locally as a side effect. That side effect can cause the destination fetch + # below to short-circuit (all objects already locally reachable via refs/tags/*), leaving + # refs/remotes/dest-tags/ empty and making every tag appear as new. + if ! git fetch --no-tags --quiet "${SOURCE_REMOTE}" "+${TAG_REF}/*:${SOURCE_REF}/*"; then + die "Failed to fetch tags from '${SOURCE_REPO}'." 2 + fi + + echo "Fetching tags from destination." + if ! git fetch --no-tags --quiet "${DEST_REMOTE}" "+${TAG_REF}/*:${DEST_REF}/*"; then + die "Failed to fetch tags from '${DEST_REPO}'." 2 + fi +} + +# Synchronize a single already-fetched tag onto the destination. Returns 0 on success and 1 on a +# recoverable per-tag failure (so the caller can continue with other tags). +sync_tag() { + local tag="$1" + + # Create tags that do not yet exist on the destination. + if ! git show-ref --verify --quiet "${DEST_REF}/${tag}"; then + echo "Creating: ${tag}" + if ! push_ref "${tag}"; then + echo "::error::Tag '${tag}' could not be created (it may have been created concurrently); skipping." + return 1 + fi + return 0 + fi + + local src_sha dest_sha + src_sha="$(git rev-parse "${SOURCE_REF}/${tag}")" + dest_sha="$(git rev-parse "${DEST_REF}/${tag}")" + + # Tags that already match need no action. + if [ "${src_sha}" = "${dest_sha}" ]; then + echo "Up-to-date: ${tag}" + return 0 + fi + + # Overwrite diverged tags when force is enabled. An explicit lease value is required: tags are + # fetched into the custom ${DEST_REF} namespace rather than a standard remote-tracking ref, so + # a bare --force-with-lease has no remote-tracking ref to compare against and always rejects + # the push as stale, even when the destination has not changed since fetch_tags ran. + if [ "${FORCE}" = "true" ]; then + echo "Overwriting: ${tag}" + if ! push_ref "${tag}" "--force-with-lease=${TAG_REF}/${tag}:${dest_sha}"; then + echo "::error::Tag '${tag}' could not be overwritten (the destination changed since fetch); skipping." + return 1 + fi + return 0 + fi + + echo "::error::Tag '${tag}' exists on both sides with different objects; skipping (use FORCE to overwrite)." + return 1 +} + +sync_tags() { + PUSH_OPTS=() + if [ "${DRY_RUN}" = "true" ]; then + echo "Dry run: no tags will be pushed." + PUSH_OPTS+=("--dry-run") + fi + + local failed=0 + local tag + while IFS= read -r tag; do + [ -n "${tag}" ] || continue + sync_tag "${tag}" || failed=1 + done < <(git for-each-ref --format='%(refname:lstrip=3)' "${SOURCE_REF}/") + + return "${failed}" +} + +# --- Main -------------------------------------------------------------------------------------- + +main() { + validate_config + setup_working_repo + fetch_tags + sync_tags +} + +main "$@" diff --git a/.github/workflows/sync-branches-tags.yml b/.github/workflows/sync-branches-tags.yml new file mode 100644 index 0000000..907f8c7 --- /dev/null +++ b/.github/workflows/sync-branches-tags.yml @@ -0,0 +1,128 @@ +# This workflow can be used to synchronize branches and tags between two repositories. By default, +# existing destination branches are fast-forwarded to match the source, but this can be changed to +# overwrite (force-push) diverged branches for manual runs, as well as to perform a dry run. Missing +# branches are always created. Tags are synced after all branch jobs complete. +name: Synchronize branches and tags between two repositories + +on: + workflow_call: + inputs: + source: + description: "Source repository to synchronize from (e.g. owner/src)." + type: string + required: true + destination: + description: "Destination repository to synchronize to (e.g. owner/dst)." + type: string + required: true + branches: + description: "Comma-separated branch names/globs to synchronize." + type: string + required: true + dry_run: + description: "Perform a dry run: report what would change without pushing anything." + type: boolean + default: false + force: + description: "Overwrite (force-push) destination branches if they have diverged from the source." + type: boolean + default: false + secrets: + gh_token: + description: "GitHub token (optionally GPG-encrypted and base64-encoded) to use for authentication, which must have access to read from the source repo and write to the destination repo." + required: true + gpg_passphrase: + description: "GPG passphrase to use for decrypting the token, if it is encrypted." + required: false + +defaults: + run: + shell: bash + +jobs: + # Determine the list of branches to synchronize into a JSON array that the matrix below expands. + get-branches: + name: Get branches + runs-on: ubuntu-latest + outputs: + branches: ${{ steps.list.outputs.branches }} + steps: + - name: Determine branch list + id: list + env: + BRANCHES: ${{ inputs.branches }} + run: | + # Convert the comma-separated list into the JSON array the matrix expects. Trim whitespace + # and drop empty entries to avoid matrix jobs with blank branch names. + JSON="$(printf '%s' "${BRANCHES}" | jq --raw-input --compact-output \ + 'split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length > 0))')" + if [ "${JSON}" = "[]" ]; then + echo "::error::No branches to synchronize after parsing '${BRANCHES}'." + exit 1 + fi + echo "branches=${JSON}" >> "$GITHUB_OUTPUT" + + # Sync each branch in its own job so they can run in parallel. + sync-branch: + name: Synchronize ${{ matrix.branch }} + needs: get-branches + runs-on: ubuntu-latest + strategy: + # Do not cancel the other branches if one fails; each branch is synchronized independently. + fail-fast: false + matrix: + branch: ${{ fromJSON(needs.get-branches.outputs.branches) }} + steps: + # Explicitly check out the current repository so we can reference the local files. + - name: Checkout workflow repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + + - name: Decrypt token + id: decrypt-token + uses: ./decrypt-token + with: + token: ${{ secrets.gh_token }} + gpg_passphrase: ${{ secrets.gpg_passphrase }} + + - name: Sync branch + env: + GH_TOKEN: ${{ steps.decrypt-token.outputs.token }} + BRANCH: ${{ matrix.branch }} + DRY_RUN: ${{ inputs.dry_run }} + FORCE: ${{ inputs.force }} + SOURCE_REPO: ${{ inputs.source }} + DEST_REPO: ${{ inputs.destination }} + run: .github/scripts/sync-branches.sh + + # Sync tags only after all branch jobs complete, so that tagged commits are always reachable from + # a branch in the destination repository before the tag pointing to them arrives. + sync-tags: + name: Synchronize tags + needs: sync-branch + runs-on: ubuntu-latest + steps: + # Explicitly check out the current repository so we can reference the local files. + - name: Checkout workflow repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + + - name: Decrypt token + id: decrypt-token + uses: ./decrypt-token + with: + token: ${{ secrets.gh_token }} + gpg_passphrase: ${{ secrets.gpg_passphrase }} + + - name: Sync tags + env: + GH_TOKEN: ${{ steps.decrypt-token.outputs.token }} + DRY_RUN: ${{ inputs.dry_run }} + FORCE: ${{ inputs.force }} + SOURCE_REPO: ${{ inputs.source }} + DEST_REPO: ${{ inputs.destination }} + run: .github/scripts/sync-tags.sh diff --git a/.github/workflows/test-clone-repo.yml b/.github/workflows/test-clone-repo.yml new file mode 100644 index 0000000..4e097da --- /dev/null +++ b/.github/workflows/test-clone-repo.yml @@ -0,0 +1,59 @@ +on: + pull_request: + paths: + - ".github/workflows/test-clone-repo.yml" + - "clone-repo/**" + push: + branches: [main] + paths: + - ".github/workflows/test-clone-repo.yml" + - "clone-repo/**" + workflow_dispatch: + schedule: + - cron: "0 8 * * *" + +defaults: + run: + shell: bash + +jobs: + test-clone-repo: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Test clone-repo + uses: ./clone-repo + with: + repository: ${{ github.repository }} + token: ${{ github.token }} + + - name: Confirm results + env: + # Use github.repository instead of github.event.repository.name, since the latter is not + # populated for schedule-triggered runs. + REPO_NAME: ${{ github.repository }} + run: | + REPO_NAME="${REPO_NAME#*/}" + cd "${REPO_NAME}" + + if [[ ! -d ".git" ]]; then + echo "Expected the repository to be cloned into a directory named '${REPO_NAME}'" + exit 1 + fi + if [[ ! -f "README.md" ]]; then + echo "Expected the cloned repository to contain README.md" + exit 1 + fi + + # The auth header must be persisted into .git/config so subsequent plain git commands + # (e.g. push) against this clone are authenticated without any further setup. + if ! git config --local --get-regexp '^http\..*\.extraheader$' >/dev/null; then + echo "Expected an authenticated HTTP extraheader to be configured on the clone" + exit 1 + fi + + # A plain git command (not routed through gh) should succeed against this clone. + git fetch origin diff --git a/.github/workflows/test-configure-git.yml b/.github/workflows/test-configure-git.yml new file mode 100644 index 0000000..62c7a9a --- /dev/null +++ b/.github/workflows/test-configure-git.yml @@ -0,0 +1,45 @@ +on: + pull_request: + paths: + - ".github/workflows/test-configure-git.yml" + - "configure-git/**" + push: + branches: [main] + paths: + - ".github/workflows/test-configure-git.yml" + - "configure-git/**" + workflow_dispatch: + schedule: + - cron: "0 8 * * *" + +defaults: + run: + shell: bash + +jobs: + test-configure-git: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Test configure-git + uses: ./configure-git + with: + user_name: Test User + user_email: test-user@example.com + + - name: Confirm results + env: + USER_NAME: Test User + USER_EMAIL: test-user@example.com + run: | + if [[ "$(git config --global user.name)" != "${USER_NAME}" ]]; then + echo "Unexpected git user.name" + exit 1 + fi + if [[ "$(git config --global user.email)" != "${USER_EMAIL}" ]]; then + echo "Unexpected git user.email" + exit 1 + fi diff --git a/.github/workflows/test-configure-github-app.yml b/.github/workflows/test-configure-github-app.yml index c7f64d5..5bffdb6 100644 --- a/.github/workflows/test-configure-github-app.yml +++ b/.github/workflows/test-configure-github-app.yml @@ -23,7 +23,7 @@ jobs: image: ghcr.io/xrplf/xrpld/nix-ubuntu:latest steps: - - name: Checkout Repo + - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Test configure-github-app @@ -51,11 +51,3 @@ jobs: echo "Unexpected app user_email" exit 1 fi - if [[ "$(git config --global user.name)" != '${{ steps.app.outputs.user_name }}' ]]; then - echo "Unexpected git user.name" - exit 1 - fi - if [[ "$(git config --global user.email)" != '${{ steps.app.outputs.user_email }}' ]]; then - echo "Unexpected git user.email" - exit 1 - fi diff --git a/.github/workflows/test-encrypt-decrypt-token.yml b/.github/workflows/test-encrypt-decrypt-token.yml new file mode 100644 index 0000000..308ffe9 --- /dev/null +++ b/.github/workflows/test-encrypt-decrypt-token.yml @@ -0,0 +1,84 @@ +on: + pull_request: + paths: + - ".github/workflows/test-encrypt-decrypt-token.yml" + - "encrypt-token/**" + - "decrypt-token/**" + push: + branches: [main] + paths: + - ".github/workflows/test-encrypt-decrypt-token.yml" + - "encrypt-token/**" + - "decrypt-token/**" + workflow_dispatch: + schedule: + - cron: "0 8 * * *" + +defaults: + run: + shell: bash + +env: + GPG_PASSPHRASE: test-passphrase + TEST_TOKEN: test-token-value + +jobs: + test-encrypt-decrypt-token: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Test encrypt-token + id: encrypt + uses: ./encrypt-token + with: + token: ${{ env.TEST_TOKEN }} + gpg_passphrase: ${{ env.GPG_PASSPHRASE }} + + - name: Confirm encryption results + env: + ENC_TOKEN: ${{ steps.encrypt.outputs.token }} + run: | + if [[ -z "${ENC_TOKEN}" ]]; then + echo "Expected token output to be set" + exit 1 + fi + if [[ "${ENC_TOKEN}" == "test-token-value" ]]; then + echo "Expected token to be encrypted, but it was returned unchanged" + exit 1 + fi + + # The output must be valid base64. + if ! echo -n "${ENC_TOKEN}" | base64 -d >/dev/null; then + echo "Expected token to be valid base64" + exit 1 + fi + + - name: Test decrypt-token with a GPG-encrypted token + id: decrypt-encrypted + uses: ./decrypt-token + with: + token: ${{ steps.encrypt.outputs.token }} + gpg_passphrase: ${{ env.GPG_PASSPHRASE }} + + - name: Test decrypt-token with a plain (unencrypted) token + id: decrypt-plain + uses: ./decrypt-token + with: + token: ${{ env.TEST_TOKEN }} + + - name: Confirm decryption results + env: + DECRYPTED: ${{ steps.decrypt-encrypted.outputs.token }} + PASSED_THROUGH: ${{ steps.decrypt-plain.outputs.token }} + run: | + if [[ "${DECRYPTED}" != "${TEST_TOKEN}" ]]; then + echo 'Expected the decrypted token to match the original.' + exit 1 + fi + if [[ "${PASSED_THROUGH}" != "${TEST_TOKEN}" ]]; then + echo 'Expected the passed-through token to match the original.' + exit 1 + fi diff --git a/.github/workflows/test-sync-branches-tags.yml b/.github/workflows/test-sync-branches-tags.yml new file mode 100644 index 0000000..d4a2768 --- /dev/null +++ b/.github/workflows/test-sync-branches-tags.yml @@ -0,0 +1,389 @@ +name: Test synchronizing branches and tags between two repositories + +on: + pull_request: + paths: + - ".github/scripts/sync-*" + - ".github/workflows/sync-branches-tags.yml" + - ".github/workflows/test-sync-branches-tags.yml" + - "clone-repo/**" + - "configure-git/**" + - "configure-github-app/**" + - "decrypt-token/**" + - "encrypt-token/**" + push: + branches: [main] + paths: + - ".github/scripts/sync-*" + - ".github/workflows/sync-branches-tags.yml" + - ".github/workflows/test-sync-branches-tags.yml" + - "clone-repo/**" + - "configure-git/**" + - "configure-github-app/**" + - "decrypt-token/**" + - "encrypt-token/**" + workflow_dispatch: + schedule: + - cron: "0 9 * * *" + +# Limit a single instance of this workflow to run at a time by gating on a single named concurrency +# group, to prevent multiple instances from trying to update the same branches and tags at the same +# time. An ongoing execution is not canceled. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +env: + # The source and destination repositories to use. + REPO_SRC: actions-sync-test-src + REPO_DST: actions-sync-test-dst + # The branch and tag names to use. + BRANCH: test-${{ github.run_id }}-${{ github.run_attempt }} + TAG: v${{ github.run_number }}.${{ github.run_attempt }} + +jobs: + # Configure the GitHub App token to use for authentication, and GPG-encrypt and base64-encode the + # token so it can be passed to subsequent jobs. + get-token: + name: Get GitHub App token + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Configure GitHub App + id: configure-app + uses: ./configure-github-app + with: + client_id: ${{ secrets.XRPLF_GITHUB_APP_CLIENT_ID }} + private_key: ${{ secrets.XRPLF_GITHUB_APP_PRIVATE_KEY }} + repositories: ${{ env.REPO_SRC }},${{ env.REPO_DST }} + # The token is passed to and used by later jobs in this workflow, so it must not be + # revoked when this job completes. + skip_token_revoke: true + + - name: Encrypt GitHub App token + id: encrypt-token + uses: ./encrypt-token + with: + token: ${{ steps.configure-app.outputs.app_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + outputs: + enc_token: ${{ steps.encrypt-token.outputs.token }} + # The username and email are needed to configure git for each commit. + user_name: ${{ steps.configure-app.outputs.user_name }} + user_email: ${{ steps.configure-app.outputs.user_email }} + + # Create the test branch and tag in the source repository. + create-branch-tag: + name: Create branch and tag + runs-on: ubuntu-latest + needs: get-token + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Decrypt GitHub App token + id: decrypt-token + uses: ./decrypt-token + with: + token: ${{ needs.get-token.outputs.enc_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + + - name: Configure git + uses: ./configure-git + with: + user_name: ${{ needs.get-token.outputs.user_name }} + user_email: ${{ needs.get-token.outputs.user_email }} + + - name: Clone the source repo + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_SRC }} + token: ${{ steps.decrypt-token.outputs.token }} + + - name: Create a test branch and tag in the source repo + working-directory: ${{ env.REPO_SRC }} + run: | + git checkout -b ${BRANCH} + git commit --allow-empty -m "Test commit" + git push --set-upstream origin ${BRANCH} + git tag ${TAG} + git push origin tag ${TAG} + outputs: + # The env context is not available in a job's `with:` when calling a reusable workflow, so + # expose these as job outputs for the sync-* jobs below to consume via `needs`. (Note that, in + # contrast, the env context is available in a step's `with:` when calling a local action.) + repo_src: ${{ env.REPO_SRC }} + repo_dst: ${{ env.REPO_DST }} + branch: ${{ env.BRANCH }} + + # Do a sync dry-run to ensure the workflow and scripts are working. + sync-dry-run: + name: Sync branches and tags (dry-run) + needs: + - get-token + - create-branch-tag + uses: ./.github/workflows/sync-branches-tags.yml + secrets: + gh_token: ${{ needs.get-token.outputs.enc_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + with: + source: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_src }} + destination: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_dst }} + branches: ${{ needs.create-branch-tag.outputs.branch }} + dry_run: true + force: false + + # Verify that the test branch and tag do not exist in the destination repo. + verify-dry-run: + name: Verify (dry-run) + runs-on: ubuntu-latest + needs: + - get-token + - create-branch-tag + - sync-dry-run + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Decrypt GitHub App token + id: decrypt-token + uses: ./decrypt-token + with: + token: ${{ needs.get-token.outputs.enc_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + + - name: Clone the destination repo + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} + token: ${{ steps.decrypt-token.outputs.token }} + + - name: Verify that the test branch and tag do not exist in the destination repo + working-directory: ${{ env.REPO_DST }} + run: | + # A plain clone only checks out the default branch under refs/heads; every other branch + # (including this test branch) is only present as a remote-tracking ref. + if git show-ref --verify --quiet refs/remotes/origin/${BRANCH}; then + echo "Branch ${BRANCH} exists in ${REPO_DST}, but should not." + exit 1 + fi + if git show-ref --verify --quiet refs/tags/${TAG}; then + echo "Tag ${TAG} exists in ${REPO_DST}, but should not." + exit 1 + fi + + # Do a regular (non-dry-run, non-force) sync, which will create the test branch and tag in the + # destination repository. + sync-regular: + name: Sync branches and tags (regular) + uses: ./.github/workflows/sync-branches-tags.yml + needs: + - get-token + - create-branch-tag + - verify-dry-run + secrets: + gh_token: ${{ needs.get-token.outputs.enc_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + with: + source: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_src }} + destination: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_dst }} + branches: ${{ needs.create-branch-tag.outputs.branch }} + dry_run: false + force: false + + # Verify that the test branch and tag now exist in the destination repository. + verify-regular: + name: Verify (regular) + runs-on: ubuntu-latest + needs: + - get-token + - create-branch-tag + - sync-regular + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Decrypt GitHub App token + id: decrypt-token + uses: ./decrypt-token + with: + token: ${{ needs.get-token.outputs.enc_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + + - name: Clone the destination repo + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} + token: ${{ steps.decrypt-token.outputs.token }} + + - name: Verify that the test branch and tag exist in the destination repo + working-directory: ${{ env.REPO_DST }} + run: | + if ! git show-ref --verify --quiet refs/remotes/origin/${BRANCH}; then + echo "Branch ${BRANCH} does not exist in ${REPO_DST}, but should." + exit 1 + fi + if ! git show-ref --verify --quiet refs/tags/${TAG}; then + echo "Tag ${TAG} does not exist in ${REPO_DST}, but should." + exit 1 + fi + + # Create a conflict in the destination repository by adding a commit to the test branch. + create-conflict: + name: Create a conflict in the destination repository + runs-on: ubuntu-latest + needs: + - get-token + - create-branch-tag + - verify-regular + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Decrypt GitHub App token + id: decrypt-token + uses: ./decrypt-token + with: + token: ${{ needs.get-token.outputs.enc_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + + - name: Configure git + uses: ./configure-git + with: + user_name: ${{ needs.get-token.outputs.user_name }} + user_email: ${{ needs.get-token.outputs.user_email }} + + - name: Clone the destination repo + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} + token: ${{ steps.decrypt-token.outputs.token }} + + - name: Create a conflict in the destination repository + id: conflict-hash + working-directory: ${{ env.REPO_DST }} + run: | + git checkout ${BRANCH} + git commit --allow-empty -m "Conflict commit" + git push --set-upstream origin ${BRANCH} + COMMIT=$(git rev-parse HEAD) + echo "commit=${COMMIT}" >> "${GITHUB_OUTPUT}" + outputs: + commit: ${{ steps.conflict-hash.outputs.commit }} + + # Do a force sync to overwrite the conflict created above. + sync-force: + name: Sync branches and tags (force) + needs: + - get-token + - create-branch-tag + - create-conflict + uses: ./.github/workflows/sync-branches-tags.yml + secrets: + gh_token: ${{ needs.get-token.outputs.enc_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + with: + source: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_src }} + destination: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_dst }} + branches: ${{ needs.create-branch-tag.outputs.branch }} + dry_run: false + force: true + + # Verify that the conflict has been overwritten. + verify-force: + name: Verify (force) + runs-on: ubuntu-latest + needs: + - get-token + - create-branch-tag + - create-conflict + - sync-force + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Decrypt GitHub App token + id: decrypt-token + uses: ./decrypt-token + with: + token: ${{ needs.get-token.outputs.enc_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + + - name: Clone the destination repo + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} + token: ${{ steps.decrypt-token.outputs.token }} + + - name: Verify that the conflict no longer exists in the destination repo + working-directory: ${{ env.REPO_DST }} + env: + COMMIT: ${{ needs.create-conflict.outputs.commit }} + run: | + git checkout ${BRANCH} + if git merge-base --is-ancestor ${COMMIT} HEAD; then + echo "Branch ${BRANCH} contains commit ${COMMIT}, but shouldn't." + exit 1 + fi + + # Clean up the test branch and tag from both repositories, and revoke the GitHub App token. Run + # the cleanup job even if previous jobs failed, and also run some of the steps unconditionally, so + # they also run if previous jobs failed. + clean-up: + name: Clean up + runs-on: ubuntu-latest + if: always() + needs: + - get-token + - create-branch-tag + - verify-force + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Decrypt GitHub App token + id: decrypt-token + uses: ./decrypt-token + with: + token: ${{ needs.get-token.outputs.enc_token }} + gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + + - name: Clone the source repo + if: always() + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_SRC }} + token: ${{ steps.decrypt-token.outputs.token }} + + - name: Remove branch and tag from the source repository + if: always() + working-directory: ${{ env.REPO_SRC }} + run: | + git push origin --delete ${BRANCH} + git push origin --delete tag ${TAG} + + - name: Clone the destination repo + if: always() + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} + token: ${{ steps.decrypt-token.outputs.token }} + + - name: Remove branch and tag from the destination repository + if: always() + working-directory: ${{ env.REPO_DST }} + run: | + git push origin --delete ${BRANCH} + git push origin --delete tag ${TAG} + + - name: Revoke GitHub App token + if: always() + env: + GH_TOKEN: ${{ steps.decrypt-token.outputs.token }} + run: gh api -X DELETE /installation/token diff --git a/README.md b/README.md index c36e49a..81b3a61 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,12 @@ Reusable workflows and actions for XRPLF repos ## Available Actions - `cleanup-workspace`: Cleans up the GitHub Actions workspace, should be used for self-hosted runners before running any steps. +- `clone-repo`: Clones a repository authenticated with a token, so that subsequent plain git commands work without additional setup. +- `configure-git`: Configures the git user identity (name and email) for the current job. +- `configure-github-app`: Creates a token for a GitHub App and exposes its git user identity, for use with `configure-git`. - `create-issue`: Creates an issue in the repository with a specified title and body. +- `decrypt-token`: Base64-decodes and GPG-decrypts a token. +- `encrypt-token`: GPG-encrypts and base64-encodes a token. - `get-nproc`: Retrieves the number of processing units available on the runner. - `prepare-runner`: Prepares the GitHub Actions runner environment for subsequent steps. - `print-build-env`: Prints environment related to the build process. @@ -18,6 +23,8 @@ Reusable workflows and actions for XRPLF repos - `determine-tidy-files.yml`: Determines which files have been modified in a Pull Request and sets an output variables with the list of those files. - `pre-commit.yml`: Runs `pre-commit` checks on code changes. - `pre-commit-autoupdate.yml`: Runs `pre-commit autoupdate` to update pre-commit hooks. +- `reusable-build-docker-image.yml`: Builds a single-platform Docker image and pushes it to a container registry. +- `sync-branches-tags.yml`: Synchronizes branches and tags between two repositories. ## Maintenance Tools diff --git a/clone-repo/action.yml b/clone-repo/action.yml new file mode 100644 index 0000000..468bda4 --- /dev/null +++ b/clone-repo/action.yml @@ -0,0 +1,29 @@ +name: Clone repository +description: Clone a repository, authenticated with a token, so that subsequent plain git commands (e.g. push) work without additional setup + +inputs: + repository: + description: The repository to clone, as 'owner/name' + required: true + token: + description: Token with access to the repository + required: true + +runs: + using: composite + steps: + - name: Clone repository + shell: bash + env: + REPOSITORY: ${{ inputs.repository }} + TOKEN: ${{ inputs.token }} + # The token is passed via an HTTP Authorization header rather than embedded in the remote URL. + # Embedding would persist the credential in .git/config and, because git prints the remote URL + # in its error messages, risk leaking it into CI logs. Passing it as an 'extraheader' keeps + # git's output limited to 'https://github.com/'. This mirrors how actions/checkout + # authenticates, and is preserved in .git/config, so subsequent plain git commands (e.g. push) + # against this clone are authenticated too. + run: | + git clone \ + -c "http.https://github.com/${REPOSITORY}.extraheader=AUTHORIZATION: basic $(printf 'x-access-token:%s' "${TOKEN}" | base64 -w0)" \ + "https://github.com/${REPOSITORY}" diff --git a/configure-git/action.yml b/configure-git/action.yml new file mode 100644 index 0000000..bc9e3ff --- /dev/null +++ b/configure-git/action.yml @@ -0,0 +1,24 @@ +name: Configure git +description: Configure the git user identity for the current job + +inputs: + user_name: + description: The git user.name to configure + required: true + user_email: + description: The git user.email to configure + required: true + +runs: + using: composite + steps: + - name: Configure git + shell: bash + env: + USER_NAME: ${{ inputs.user_name }} + USER_EMAIL: ${{ inputs.user_email }} + run: | + git config --global user.name "${USER_NAME}" + git config --global user.email "${USER_EMAIL}" + # Disable nagging message. + git config --global advice.defaultBranchName false diff --git a/configure-github-app/action.yml b/configure-github-app/action.yml index 73df837..995d0bf 100644 --- a/configure-github-app/action.yml +++ b/configure-github-app/action.yml @@ -1,5 +1,5 @@ name: Configure GitHub App -description: Create a token for a GitHub App and configure the app as the git user +description: Create a token for a GitHub App and expose its git user identity inputs: client_id: @@ -11,17 +11,21 @@ inputs: repositories: description: One or more comma-separated repositories to create the token for required: true + skip_token_revoke: + description: If true, the token will not be revoked when the current job completes (set this when the token is passed to and used by other jobs) + required: false + default: "false" outputs: app_token: description: The token for the GitHub App value: ${{ steps.app-token.outputs.token }} user_name: - description: The name of the GitHub App - value: ${{ steps.configure-git.outputs.user_name }} + description: The name of the GitHub App, for use with the `configure-git` action + value: ${{ steps.git-user.outputs.user_name }} user_email: - description: The email address of the GitHub App - value: ${{ steps.configure-git.outputs.user_email }} + description: The email address of the GitHub App, for use with the `configure-git` action + value: ${{ steps.git-user.outputs.user_email }} runs: using: composite @@ -34,6 +38,7 @@ runs: private-key: ${{ inputs.private_key }} owner: ${{ github.repository_owner }} repositories: ${{ inputs.repositories }} + skip-token-revoke: ${{ inputs.skip_token_revoke }} - name: Get GitHub App User ID id: app-user @@ -44,16 +49,11 @@ runs: USER_ID=$(gh api '/users/${{ steps.app-token.outputs.app-slug }}[bot]' --jq .id) echo "user_id=${USER_ID}" >> "${GITHUB_OUTPUT}" - - name: Configure git - id: configure-git + - name: Determine git user identity + id: git-user shell: bash run: | USER_NAME='${{ steps.app-token.outputs.app-slug }}[bot]' echo "user_name=${USER_NAME}" >> "${GITHUB_OUTPUT}" USER_EMAIL='${{ steps.app-user.outputs.user_id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com' echo "user_email=${USER_EMAIL}" >> "${GITHUB_OUTPUT}" - - git config --global user.name "${USER_NAME}" - git config --global user.email "${USER_EMAIL}" - # Disable nagging message. - git config --global advice.defaultBranchName false diff --git a/decrypt-token/action.yml b/decrypt-token/action.yml new file mode 100644 index 0000000..e758b48 --- /dev/null +++ b/decrypt-token/action.yml @@ -0,0 +1,33 @@ +name: Decrypt token +description: Base64-decodes and GPG-decrypts a token if a GPG passphrase is provided, otherwise passes the token through unchanged + +inputs: + token: + description: The base64-encoded, optionally GPG-encrypted token + required: true + gpg_passphrase: + description: The GPG passphrase the token was encrypted with (if not provided, the token is passed through unchanged) + required: false + +outputs: + token: + description: The decrypted (or passed-through) token + value: ${{ steps.decrypt.outputs.token }} + +runs: + using: composite + steps: + - name: Decrypt token + id: decrypt + shell: bash + env: + ENC_TOKEN: ${{ inputs.token }} + GPG_PASSPHRASE: ${{ inputs.gpg_passphrase }} + run: | + if [ -n "${GPG_PASSPHRASE}" ]; then + TOKEN=$(echo -n "${ENC_TOKEN}" | base64 -d | gpg --decrypt --batch --passphrase "${GPG_PASSPHRASE}") + else + TOKEN="${ENC_TOKEN}" + fi + echo "::add-mask::${TOKEN}" + echo "token=${TOKEN}" >> "${GITHUB_OUTPUT}" diff --git a/encrypt-token/action.yml b/encrypt-token/action.yml new file mode 100644 index 0000000..4ca7041 --- /dev/null +++ b/encrypt-token/action.yml @@ -0,0 +1,28 @@ +name: Encrypt token +description: GPG-encrypt and base64-encode a token + +inputs: + token: + description: The token to encrypt + required: true + gpg_passphrase: + description: The GPG passphrase to encrypt the token with + required: true + +outputs: + token: + description: The base64-encoded, GPG-encrypted token + value: ${{ steps.encrypt.outputs.token }} + +runs: + using: composite + steps: + - name: Encrypt token + id: encrypt + shell: bash + env: + TOKEN: ${{ inputs.token }} + GPG_PASSPHRASE: ${{ inputs.gpg_passphrase }} + run: | + ENC_TOKEN=$(gpg --symmetric --batch --passphrase "${GPG_PASSPHRASE}" --output - <(echo -n "${TOKEN}") | base64 -w0) + echo "token=${ENC_TOKEN}" >> "${GITHUB_OUTPUT}" From 06e22f047b0e9720a698d6c99519e552df4d5b1b Mon Sep 17 00:00:00 2001 From: Bart Date: Fri, 24 Jul 2026 11:15:27 -0400 Subject: [PATCH 2/9] Fix unbound variable error --- .github/scripts/sync-common-functions.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/sync-common-functions.sh b/.github/scripts/sync-common-functions.sh index b3d393a..7a37806 100755 --- a/.github/scripts/sync-common-functions.sh +++ b/.github/scripts/sync-common-functions.sh @@ -37,7 +37,8 @@ push_ref() { # repository ignores a valid token, so the same header is used for both. # Args: add_authenticated_remote() { - local remote="$1" repo="$2" url="https://github.com/${repo}" + local remote="$1" repo="$2" + local url="https://github.com/${repo}" git remote add "${remote}" "${url}" git config --local "http.${url}/.extraheader" \ "AUTHORIZATION: basic $(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n')" From ec579aa0ca12de89f9e9a6024bea490d75d6b9a3 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 27 Jul 2026 15:09:38 -0400 Subject: [PATCH 3/9] Address review feedback --- .github/scripts/sync-branches.sh | 157 +++++++----------- .github/scripts/sync-common-functions.sh | 40 ++--- .github/scripts/sync-tags.sh | 68 +++----- .github/workflows/sync-branches-tags.yml | 16 +- .github/workflows/test-clone-repo.yml | 71 ++++---- .github/workflows/test-configure-git.yml | 2 +- .../workflows/test-configure-github-app.yml | 2 +- .../workflows/test-encrypt-decrypt-token.yml | 6 +- .github/workflows/test-sync-branches-tags.yml | 106 +----------- README.md | 9 +- 10 files changed, 155 insertions(+), 322 deletions(-) mode change 100755 => 100644 .github/scripts/sync-common-functions.sh diff --git a/.github/scripts/sync-branches.sh b/.github/scripts/sync-branches.sh index 8b708b0..4dbf4c0 100755 --- a/.github/scripts/sync-branches.sh +++ b/.github/scripts/sync-branches.sh @@ -1,36 +1,34 @@ #!/usr/bin/env bash # # Synchronize important branches from a source repository into a destination repository. Missing -# branches are always created. By default branches are fast-forwarded only, while diverged branches -# are skipped and cause a non-zero exit. Force mode overwrites diverged branches with a force-push -# (using --force-with-lease). Dry-run mode reports what would change without pushing anything. +# branches are always created. By default branches are fast-forwarded only, while a diverged branch +# aborts the run. Force mode overwrites diverged branches with a force-push (using +# --force-with-lease). Dry-run mode reports what would change without pushing anything. # # Configuration is supplied via environment variables: # # Required: -# GH_TOKEN Token with read access to the source repository and write access to the -# destination. A public repository ignores the token as long as it is valid. When -# running this script locally, your personal access token must have at least the -# "repo", "read:org" (for branches in organization repos), and "workflow" scopes. -# SOURCE_REPO Source repository, as "owner/repo" (github.com is assumed). -# DEST_REPO Destination repository, as "owner/repo" (github.com is assumed). -# BRANCH A single branch name or glob to synchronize. A glob may match several branches, -# all of which are synchronized. +# GH_TOKEN Token with read access to the source repository and write access to the +# destination. A public repository ignores the token as long as it is valid. When +# running this script locally, your personal access token must have at least the +# "repo", "read:org" (for branches in organization repos), and "workflow" scopes. +# SOURCE_REPO Source repository, as "owner/repo" (github.com is assumed). +# DEST_REPO Destination repository, as "owner/repo" (github.com is assumed). +# BRANCHES_GLOB Glob matching the branches to synchronize. A plain branch name is a glob that +# matches only itself; a wider glob may match several branches, all of which are +# synchronized. # # Optional: -# DRY_RUN "true" to report changes without pushing. -# (default: false) -# FORCE "true" to overwrite diverged destination branches. -# (default: false) -# WORKDIR Directory to initialize the working repo in. -# (default: a fresh mktemp dir) +# DRY_RUN "true" to report changes without pushing. +# (default: false) +# FORCE "true" to overwrite diverged destination branches. +# (default: false) # # Exit status: -# 0 All branches synchronized successfully (or a clean dry run was performed). -# 1 At least one branch could not be synchronized (diverged without FORCE, or a push was -# rejected because the destination changed concurrently); other branches were still -# attempted. -# 2 Invalid configuration / usage error. +# 0 All matched branches synchronized successfully (or a clean dry run was performed). +# non-zero Synchronization failed, and the run stopped at the first branch that could not be +# synchronized. Failures that git detects (an unreachable remote, a missing branch, a +# rejected push) exit with git's own status and message. set -euo pipefail @@ -40,7 +38,7 @@ source "${BASH_SOURCE[0]%/*}/sync-common-functions.sh" # --- Configuration ----------------------------------------------------------------------------- set_common_defaults -BRANCH="${BRANCH:-}" +BRANCHES_GLOB="${BRANCHES_GLOB:-}" # Internal git ref/remote names used within the working repository. BRANCH_REF="refs/heads" @@ -52,118 +50,75 @@ REMOTE_REF_PREFIX="${BRANCH_REF}" # --- Helpers ----------------------------------------------------------------------------------- -# Validate the configuration supplied via the environment, exiting with status 2 on any problem. validate_config() { validate_common_config - [ -n "${BRANCH}" ] || die "BRANCH must be a non-empty branch name or glob." 2 + [ -n "${BRANCHES_GLOB}" ] || die "BRANCHES_GLOB must be a non-empty branch name or glob." } -# Fetch the branch (or all branches matching the glob) from both remotes into local tracking refs. -# The '+' prefix force-updates the tracking refs so they always reflect each remote. +# Fetch the branches matching the glob from both remotes into local tracking refs. The '+' prefix +# force-updates the tracking refs so they always reflect each remote. fetch_branches() { - # Unlike the destination, a missing source branch is a real error as there is nothing to - # synchronize from. Both "no matching refs" and other failures are fatal, but they get distinct - # messages to aid diagnosis, and both exit 2 to match the documented usage/config status rather - # than git's bare exit 1. The 'git ls-remote --exit-code' command returns 2 only when no ref - # matches, so we capture the status with '|| ls_status=$?' to ensure a non-zero exit does not - # trip 'set -e' before it is inspected. Any other non-zero status than 2 is treated as a real - # failure. - echo "Fetching source branch: ${BRANCH}" - if ! git fetch --quiet "${SOURCE_REMOTE}" "+${BRANCH_REF}/${BRANCH}:${SOURCE_REF}/${BRANCH}"; then - local ls_status=0 - git ls-remote --exit-code --heads "${SOURCE_REMOTE}" "${BRANCH_REF}/${BRANCH}" >/dev/null 2>&1 || ls_status=$? - if [ "${ls_status}" -eq 2 ]; then - die "Source branch(es) '${BRANCH}' not found in '${SOURCE_REPO}'." 2 - fi - die "Failed to fetch source branch(es) '${BRANCH}' from '${SOURCE_REPO}'." 2 - fi - - # A glob that matches no refs makes 'git fetch' succeed having fetched nothing (git does not - # treat an empty glob match as an error), so a missing or mistyped BRANCH would otherwise go - # undetected. - if [ -z "$(git for-each-ref "${SOURCE_REF}/")" ]; then - die "Source branch(es) '${BRANCH}' not found in '${SOURCE_REPO}'." 2 - fi - - # A literal (non-glob) branch that does not yet exist on the destination makes 'git fetch' fail; - # that case is tolerated here and the branch is created below. Any other failure (e.g. a - # transient network or remote error) must not be masked, or the branch would be wrongly treated - # as missing. - echo "Fetching destination branch: ${BRANCH}" - if ! git fetch --quiet "${DEST_REMOTE}" "+${BRANCH_REF}/${BRANCH}:${DEST_REF}/${BRANCH}"; then - local ls_status=0 - git ls-remote --exit-code --heads "${DEST_REMOTE}" "${BRANCH_REF}/${BRANCH}" >/dev/null 2>&1 || ls_status=$? - if [ "${ls_status}" -ne 2 ]; then - die "Failed to fetch destination branch(es) '${BRANCH}' from '${DEST_REPO}'." 2 - fi - fi + echo "Fetching source branch(es): ${BRANCHES_GLOB}" + git fetch --quiet "${SOURCE_REMOTE}" \ + "+${BRANCH_REF}/${BRANCHES_GLOB}:${SOURCE_REF}/${BRANCHES_GLOB}" + + # A glob that matches no refs makes 'git fetch' succeed having fetched nothing, so a mistyped + # BRANCHES_GLOB would otherwise go undetected. + [ -n "$(git for-each-ref "${SOURCE_REF}/")" ] || + die "Source branch(es) '${BRANCHES_GLOB}' not found in '${SOURCE_REPO}'." + + # A branch that does not yet exist on the destination is expected: it is created below. Such a + # fetch fails, so failures are ignored here; a genuine problem (unreachable remote, invalid + # token) surfaces when pushing. + echo "Fetching destination branch(es): ${BRANCHES_GLOB}" + git fetch --quiet "${DEST_REMOTE}" \ + "+${BRANCH_REF}/${BRANCHES_GLOB}:${DEST_REF}/${BRANCHES_GLOB}" || true } -# Synchronize a single already-fetched ref onto the destination. Returns 0 on success and 1 on a -# recoverable per-branch failure (so the caller can continue with other branches). +# Synchronize a single already-fetched ref onto the destination. sync_ref() { local ref="$1" # Create branches that do not yet exist on the destination. The refspec is not forced, so if a - # racing push created the branch between our fetch and now, the destination is rejected rather - # than clobbered; this is recorded as a failure and the next branch is still attempted. + # racing push created the branch between our fetch and now, the push is rejected rather than + # clobbering the destination. if ! git show-ref --verify --quiet "${DEST_REF}/${ref}"; then echo "Creating: ${ref}" - if ! push_ref "${ref}"; then - echo "::error::Branch '${ref}' could not be created (it may have been created concurrently); skipping." - return 1 - fi - return 0 + push_ref "${ref}" + return fi # Overwrite diverged branches when force is enabled. The --force-with-lease flag guards against - # clobbering destination commits that appeared after our fetch: if the destination advanced in - # the meantime the push is rejected; this is recorded as a failure and the next branch is still - # attempted. + # clobbering destination commits that appeared after our fetch. if [ "${FORCE}" = "true" ]; then echo "Overwriting: ${ref}" - if ! push_ref "${ref}" --force-with-lease; then - echo "::error::Branch '${ref}' could not be overwritten (the destination changed since fetch); skipping." - return 1 - fi - return 0 + push_ref "${ref}" --force-with-lease + return fi - # Check out the destination branch and attempt a fast-forward to the source. + # Check out the destination branch and fast-forward it to the source. echo "Syncing: ${ref}" git checkout --quiet -B "${ref}" "${DEST_REF}/${ref}" - if ! git merge --ff-only "${SOURCE_REF}/${ref}"; then - echo "::error::Branch '${ref}' has diverged and cannot be fast-forwarded; skipping." - return 1 - fi - - if ! git push "${PUSH_OPTS[@]+"${PUSH_OPTS[@]}"}" "${DEST_REMOTE}" "HEAD:${BRANCH_REF}/${ref}"; then - echo "::error::Branch '${ref}' could not be pushed (the destination changed since fetch); skipping." - return 1 - fi + git merge --ff-only "${SOURCE_REF}/${ref}" || + die "Branch '${ref}' has diverged and cannot be fast-forwarded; use FORCE to overwrite it." + git push "${PUSH_OPTS[@]+"${PUSH_OPTS[@]}"}" "${DEST_REMOTE}" "HEAD:${BRANCH_REF}/${ref}" } -# Synchronize every ref that was fetched under SOURCE_REF (a glob may match several). Returns 1 if -# any branch failed to synchronize, 0 otherwise. +# Synchronize every ref that was fetched under SOURCE_REF (a glob may match several). sync_branches() { - # A dry run adds --dry-run to every push so nothing is actually written. The refspecs are - # expanded as "${PUSH_OPTS[@]+"${PUSH_OPTS[@]}"}" to avoid the "unbound variable" error that an - # empty array triggers under 'set -u' on older versions of Bash. + # A dry run adds --dry-run to every push so nothing is actually written. The options are expanded + # as "${PUSH_OPTS[@]+"${PUSH_OPTS[@]}"}" to avoid the "unbound variable" error that an empty + # array triggers under 'set -u' on older versions of Bash. PUSH_OPTS=() if [ "${DRY_RUN}" = "true" ]; then echo "Dry run: no branches will be pushed." PUSH_OPTS+=("--dry-run") fi - # Process substitution feeds the loop so it runs in the current shell and 'failed' persists. - local failed=0 local ref while IFS= read -r ref; do - [ -n "${ref}" ] || continue - sync_ref "${ref}" || failed=1 + sync_ref "${ref}" done < <(git for-each-ref --format='%(refname:lstrip=3)' "${SOURCE_REF}/") - - return "${failed}" } # --- Main -------------------------------------------------------------------------------------- diff --git a/.github/scripts/sync-common-functions.sh b/.github/scripts/sync-common-functions.sh old mode 100755 new mode 100644 index 7a37806..5a8f5d9 --- a/.github/scripts/sync-common-functions.sh +++ b/.github/scripts/sync-common-functions.sh @@ -1,13 +1,11 @@ -#!/usr/bin/env bash +# shellcheck shell=bash # -# Common helpers shared by sync-branches.sh and sync-tags.sh. Intended to be sourced, not executed -# directly. +# Common helpers shared by sync-branches.sh and sync-tags.sh. -# Emit an error and exit with the given status (default 2 for usage/config errors). +# Emit an error and exit. die() { - local status="${2:-2}" echo "::error::$1" >&2 - exit "${status}" + exit 1 } # Apply the defaults shared by all sync scripts. These match what is set in the workflow file, so @@ -44,37 +42,29 @@ add_authenticated_remote() { "AUTHORIZATION: basic $(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n')" } -# Validate configuration shared by all sync scripts: GH_TOKEN, SOURCE_REPO, DEST_REPO, DRY_RUN, -# FORCE, and git presence. +# Validate configuration shared by all sync scripts. validate_common_config() { - [ -n "${GH_TOKEN:-}" ] || die "GH_TOKEN is required but not set." 2 - [ -n "${SOURCE_REPO:-}" ] || die "SOURCE_REPO is required but not set." 2 - [ -n "${DEST_REPO:-}" ] || die "DEST_REPO is required but not set." 2 + [ -n "${GH_TOKEN:-}" ] || die "GH_TOKEN is required but not set." + [ -n "${SOURCE_REPO:-}" ] || die "SOURCE_REPO is required but not set." + [ -n "${DEST_REPO:-}" ] || die "DEST_REPO is required but not set." case "${DRY_RUN}" in true | false) ;; - *) die "DRY_RUN must be 'true' or 'false', got '${DRY_RUN}'." 2 ;; + *) die "DRY_RUN must be 'true' or 'false', got '${DRY_RUN}'." ;; esac case "${FORCE}" in true | false) ;; - *) die "FORCE must be 'true' or 'false', got '${FORCE}'." 2 ;; + *) die "FORCE must be 'true' or 'false', got '${FORCE}'." ;; esac - - command -v git >/dev/null 2>&1 || die "git is required but not found on PATH." 2 } -# Initialize the working repository and wire up the authenticated remotes. Reachability is not -# checked here, because if the first real fetch against each remote (in fetch_branches/fetch_tags) -# fails it will provide a clear message if the repository is unreachable or the token is invalid, so -# checking it here would be redundant. +# Initialize a fresh working repository in a temporary directory and wire up the authenticated +# remotes. setup_working_repo() { - if [ -z "${WORKDIR:-}" ]; then - WORKDIR="$(mktemp -d)" - trap 'rm -rf "${WORKDIR}"' EXIT - fi - mkdir -p "${WORKDIR}" - cd "${WORKDIR}" || exit + WORKDIR="$(mktemp -d)" + trap 'rm -rf "${WORKDIR}"' EXIT + cd "${WORKDIR}" git init --quiet # Both remotes carry the token; a public source simply ignores it as long as the token is valid. diff --git a/.github/scripts/sync-tags.sh b/.github/scripts/sync-tags.sh index b44280e..6ca9006 100755 --- a/.github/scripts/sync-tags.sh +++ b/.github/scripts/sync-tags.sh @@ -1,10 +1,9 @@ #!/usr/bin/env bash # # Synchronize tags from a source repository into a destination repository. Missing tags are always -# created. By default existing tags that match on both sides are a no-op; diverged tags (same name, -# different object) are skipped and cause a non-zero exit. Force mode overwrites diverged tags with -# a force-push (using --force-with-lease). Dry-run mode reports what would change without pushing -# anything. +# created. By default existing tags that match on both sides are a no-op, while a diverged tag (same +# name, different object) aborts the run. Force mode overwrites diverged tags with a force-push +# (using --force-with-lease). Dry-run mode reports what would change without pushing anything. # # This script is intended to run after the branches have been synchronized (see sync-branches.sh), # so that tagged commits are already reachable from a branch in the destination before their tags @@ -25,14 +24,12 @@ # (default: false) # FORCE "true" to overwrite diverged destination tags. # (default: false) -# WORKDIR Directory to initialize the working repo in. -# (default: a fresh mktemp dir) # # Exit status: -# 0 All tags synchronized successfully (or a clean dry run was performed). -# 1 At least one tag could not be synchronized (diverged without FORCE, or a push was -# rejected because the destination changed concurrently); other tags were still attempted. -# 2 Invalid configuration / usage error. +# 0 All tags synchronized successfully (or a clean dry run was performed). +# non-zero Synchronization failed, and the run stopped at the first tag that could not be +# synchronized. Failures that git detects (an unreachable remote, a rejected push) exit +# with git's own status and message. set -euo pipefail @@ -53,40 +50,27 @@ REMOTE_REF_PREFIX="${TAG_REF}" # --- Helpers ----------------------------------------------------------------------------------- -# Validate the configuration supplied via the environment, exiting with status 2 on any problem. -validate_config() { - validate_common_config -} - +# Fetch all tags from both remotes into local tracking refs. --no-tags suppresses git's automatic +# tag-following, which would otherwise populate refs/tags/* locally as a side effect. That side +# effect can cause the destination fetch to short-circuit (all objects already locally reachable via +# refs/tags/*), leaving refs/remotes/dest-tags/ empty and making every tag appear as new. fetch_tags() { echo "Fetching tags from source." - # --no-tags suppresses git's automatic tag-following, which would otherwise populate - # refs/tags/* locally as a side effect. That side effect can cause the destination fetch - # below to short-circuit (all objects already locally reachable via refs/tags/*), leaving - # refs/remotes/dest-tags/ empty and making every tag appear as new. - if ! git fetch --no-tags --quiet "${SOURCE_REMOTE}" "+${TAG_REF}/*:${SOURCE_REF}/*"; then - die "Failed to fetch tags from '${SOURCE_REPO}'." 2 - fi + git fetch --no-tags --quiet "${SOURCE_REMOTE}" "+${TAG_REF}/*:${SOURCE_REF}/*" echo "Fetching tags from destination." - if ! git fetch --no-tags --quiet "${DEST_REMOTE}" "+${TAG_REF}/*:${DEST_REF}/*"; then - die "Failed to fetch tags from '${DEST_REPO}'." 2 - fi + git fetch --no-tags --quiet "${DEST_REMOTE}" "+${TAG_REF}/*:${DEST_REF}/*" } -# Synchronize a single already-fetched tag onto the destination. Returns 0 on success and 1 on a -# recoverable per-tag failure (so the caller can continue with other tags). +# Synchronize a single already-fetched tag onto the destination. sync_tag() { local tag="$1" # Create tags that do not yet exist on the destination. if ! git show-ref --verify --quiet "${DEST_REF}/${tag}"; then echo "Creating: ${tag}" - if ! push_ref "${tag}"; then - echo "::error::Tag '${tag}' could not be created (it may have been created concurrently); skipping." - return 1 - fi - return 0 + push_ref "${tag}" + return fi local src_sha dest_sha @@ -96,7 +80,7 @@ sync_tag() { # Tags that already match need no action. if [ "${src_sha}" = "${dest_sha}" ]; then echo "Up-to-date: ${tag}" - return 0 + return fi # Overwrite diverged tags when force is enabled. An explicit lease value is required: tags are @@ -105,15 +89,11 @@ sync_tag() { # the push as stale, even when the destination has not changed since fetch_tags ran. if [ "${FORCE}" = "true" ]; then echo "Overwriting: ${tag}" - if ! push_ref "${tag}" "--force-with-lease=${TAG_REF}/${tag}:${dest_sha}"; then - echo "::error::Tag '${tag}' could not be overwritten (the destination changed since fetch); skipping." - return 1 - fi - return 0 + push_ref "${tag}" "--force-with-lease=${TAG_REF}/${tag}:${dest_sha}" + return fi - echo "::error::Tag '${tag}' exists on both sides with different objects; skipping (use FORCE to overwrite)." - return 1 + die "Tag '${tag}' exists on both sides with different objects; use FORCE to overwrite it." } sync_tags() { @@ -123,20 +103,16 @@ sync_tags() { PUSH_OPTS+=("--dry-run") fi - local failed=0 local tag while IFS= read -r tag; do - [ -n "${tag}" ] || continue - sync_tag "${tag}" || failed=1 + sync_tag "${tag}" done < <(git for-each-ref --format='%(refname:lstrip=3)' "${SOURCE_REF}/") - - return "${failed}" } # --- Main -------------------------------------------------------------------------------------- main() { - validate_config + validate_common_config setup_working_repo fetch_tags sync_tags diff --git a/.github/workflows/sync-branches-tags.yml b/.github/workflows/sync-branches-tags.yml index 907f8c7..caa3dd7 100644 --- a/.github/workflows/sync-branches-tags.yml +++ b/.github/workflows/sync-branches-tags.yml @@ -1,7 +1,7 @@ -# This workflow can be used to synchronize branches and tags between two repositories. By default, -# existing destination branches are fast-forwarded to match the source, but this can be changed to -# overwrite (force-push) diverged branches for manual runs, as well as to perform a dry run. Missing -# branches are always created. Tags are synced after all branch jobs complete. +# This workflow can be used to synchronize branches and tags between two repositories. Existing +# destination branches are fast-forwarded to match the source, and missing branches are created. A +# branch that has diverged from the source is never overwritten; the run fails instead. A dry run can +# be performed to report what would change. Tags are synced after all branch jobs complete. name: Synchronize branches and tags between two repositories on: @@ -23,10 +23,6 @@ on: description: "Perform a dry run: report what would change without pushing anything." type: boolean default: false - force: - description: "Overwrite (force-push) destination branches if they have diverged from the source." - type: boolean - default: false secrets: gh_token: description: "GitHub token (optionally GPG-encrypted and base64-encoded) to use for authentication, which must have access to read from the source repo and write to the destination repo." @@ -90,9 +86,8 @@ jobs: - name: Sync branch env: GH_TOKEN: ${{ steps.decrypt-token.outputs.token }} - BRANCH: ${{ matrix.branch }} + BRANCHES_GLOB: ${{ matrix.branch }} DRY_RUN: ${{ inputs.dry_run }} - FORCE: ${{ inputs.force }} SOURCE_REPO: ${{ inputs.source }} DEST_REPO: ${{ inputs.destination }} run: .github/scripts/sync-branches.sh @@ -122,7 +117,6 @@ jobs: env: GH_TOKEN: ${{ steps.decrypt-token.outputs.token }} DRY_RUN: ${{ inputs.dry_run }} - FORCE: ${{ inputs.force }} SOURCE_REPO: ${{ inputs.source }} DEST_REPO: ${{ inputs.destination }} run: .github/scripts/sync-tags.sh diff --git a/.github/workflows/test-clone-repo.yml b/.github/workflows/test-clone-repo.yml index 4e097da..6e45639 100644 --- a/.github/workflows/test-clone-repo.yml +++ b/.github/workflows/test-clone-repo.yml @@ -20,40 +20,55 @@ jobs: test-clone-repo: runs-on: ubuntu-latest + # The push check below needs write access to be able to verify that the token persisted by the + # action actually authenticates. Nothing is ever written, as the push is a dry run. + permissions: + contents: write + steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Test clone-repo + # The clone lands in a directory named after the repository. GITHUB_REPOSITORY is used instead + # of github.event.repository.name, since the latter is not populated for schedule-triggered + # runs, and is exposed as an env var so every check below can use it as its working directory. + - name: Determine the expected clone directory + run: echo "REPO_NAME=${GITHUB_REPOSITORY#*/}" >> "${GITHUB_ENV}" + + - name: Run clone-repo uses: ./clone-repo with: repository: ${{ github.repository }} token: ${{ github.token }} - - name: Confirm results - env: - # Use github.repository instead of github.event.repository.name, since the latter is not - # populated for schedule-triggered runs. - REPO_NAME: ${{ github.repository }} - run: | - REPO_NAME="${REPO_NAME#*/}" - cd "${REPO_NAME}" - - if [[ ! -d ".git" ]]; then - echo "Expected the repository to be cloned into a directory named '${REPO_NAME}'" - exit 1 - fi - if [[ ! -f "README.md" ]]; then - echo "Expected the cloned repository to contain README.md" - exit 1 - fi - - # The auth header must be persisted into .git/config so subsequent plain git commands - # (e.g. push) against this clone are authenticated without any further setup. - if ! git config --local --get-regexp '^http\..*\.extraheader$' >/dev/null; then - echo "Expected an authenticated HTTP extraheader to be configured on the clone" - exit 1 - fi - - # A plain git command (not routed through gh) should succeed against this clone. - git fetch origin + - name: Confirm the repository was cloned into a directory named after it + run: test -d "${REPO_NAME}" + + - name: Confirm the clone is a git repository + working-directory: ${{ env.REPO_NAME }} + run: test -d ".git" + + - name: Confirm the working tree was checked out + working-directory: ${{ env.REPO_NAME }} + run: test -f README.md + + # The auth header must be persisted into .git/config so subsequent plain git commands (e.g. + # push) against this clone are authenticated without any further setup. Only the config key + # names are printed, to keep the header value out of the logs. + - name: Confirm the authenticated HTTP extraheader is persisted + working-directory: ${{ env.REPO_NAME }} + run: git config --local --name-only --get-regexp '^http\..*\.extraheader$' + + # The token belongs in the extraheader, not in the remote URL, where git would otherwise leak + # it into its error messages. + - name: Confirm the token is not embedded in the remote URL + working-directory: ${{ env.REPO_NAME }} + run: test "$(git remote get-url origin)" = "https://github.com/${GITHUB_REPOSITORY}" + + # A push is what actually proves the persisted credential works: unlike a fetch, which succeeds + # anonymously on a public repository, a push is rejected without a token that has write access. + # --dry-run means nothing is written, and the refspec targets the branch that was just cloned, + # so the push is a no-op even if it were to run for real. + - name: Confirm a plain git command is authenticated against the clone + working-directory: ${{ env.REPO_NAME }} + run: git push --dry-run origin "HEAD:refs/heads/$(git rev-parse --abbrev-ref HEAD)" diff --git a/.github/workflows/test-configure-git.yml b/.github/workflows/test-configure-git.yml index 62c7a9a..bed531f 100644 --- a/.github/workflows/test-configure-git.yml +++ b/.github/workflows/test-configure-git.yml @@ -24,7 +24,7 @@ jobs: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Test configure-git + - name: Run configure-git uses: ./configure-git with: user_name: Test User diff --git a/.github/workflows/test-configure-github-app.yml b/.github/workflows/test-configure-github-app.yml index 5bffdb6..774600e 100644 --- a/.github/workflows/test-configure-github-app.yml +++ b/.github/workflows/test-configure-github-app.yml @@ -26,7 +26,7 @@ jobs: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Test configure-github-app + - name: Run configure-github-app id: app uses: ./configure-github-app with: diff --git a/.github/workflows/test-encrypt-decrypt-token.yml b/.github/workflows/test-encrypt-decrypt-token.yml index 308ffe9..bbe7502 100644 --- a/.github/workflows/test-encrypt-decrypt-token.yml +++ b/.github/workflows/test-encrypt-decrypt-token.yml @@ -30,7 +30,7 @@ jobs: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Test encrypt-token + - name: Run encrypt-token id: encrypt uses: ./encrypt-token with: @@ -56,14 +56,14 @@ jobs: exit 1 fi - - name: Test decrypt-token with a GPG-encrypted token + - name: Run decrypt-token with a GPG-encrypted token id: decrypt-encrypted uses: ./decrypt-token with: token: ${{ steps.encrypt.outputs.token }} gpg_passphrase: ${{ env.GPG_PASSPHRASE }} - - name: Test decrypt-token with a plain (unencrypted) token + - name: Run decrypt-token with a plain (unencrypted) token id: decrypt-plain uses: ./decrypt-token with: diff --git a/.github/workflows/test-sync-branches-tags.yml b/.github/workflows/test-sync-branches-tags.yml index d4a2768..141a596 100644 --- a/.github/workflows/test-sync-branches-tags.yml +++ b/.github/workflows/test-sync-branches-tags.yml @@ -137,7 +137,6 @@ jobs: destination: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_dst }} branches: ${{ needs.create-branch-tag.outputs.branch }} dry_run: true - force: false # Verify that the test branch and tag do not exist in the destination repo. verify-dry-run: @@ -178,8 +177,8 @@ jobs: exit 1 fi - # Do a regular (non-dry-run, non-force) sync, which will create the test branch and tag in the - # destination repository. + # Do a regular (non-dry-run) sync, which will create the test branch and tag in the destination + # repository. sync-regular: name: Sync branches and tags (regular) uses: ./.github/workflows/sync-branches-tags.yml @@ -195,7 +194,6 @@ jobs: destination: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_dst }} branches: ${{ needs.create-branch-tag.outputs.branch }} dry_run: false - force: false # Verify that the test branch and tag now exist in the destination repository. verify-regular: @@ -234,104 +232,6 @@ jobs: exit 1 fi - # Create a conflict in the destination repository by adding a commit to the test branch. - create-conflict: - name: Create a conflict in the destination repository - runs-on: ubuntu-latest - needs: - - get-token - - create-branch-tag - - verify-regular - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Decrypt GitHub App token - id: decrypt-token - uses: ./decrypt-token - with: - token: ${{ needs.get-token.outputs.enc_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} - - - name: Configure git - uses: ./configure-git - with: - user_name: ${{ needs.get-token.outputs.user_name }} - user_email: ${{ needs.get-token.outputs.user_email }} - - - name: Clone the destination repo - uses: ./clone-repo - with: - repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} - token: ${{ steps.decrypt-token.outputs.token }} - - - name: Create a conflict in the destination repository - id: conflict-hash - working-directory: ${{ env.REPO_DST }} - run: | - git checkout ${BRANCH} - git commit --allow-empty -m "Conflict commit" - git push --set-upstream origin ${BRANCH} - COMMIT=$(git rev-parse HEAD) - echo "commit=${COMMIT}" >> "${GITHUB_OUTPUT}" - outputs: - commit: ${{ steps.conflict-hash.outputs.commit }} - - # Do a force sync to overwrite the conflict created above. - sync-force: - name: Sync branches and tags (force) - needs: - - get-token - - create-branch-tag - - create-conflict - uses: ./.github/workflows/sync-branches-tags.yml - secrets: - gh_token: ${{ needs.get-token.outputs.enc_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} - with: - source: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_src }} - destination: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_dst }} - branches: ${{ needs.create-branch-tag.outputs.branch }} - dry_run: false - force: true - - # Verify that the conflict has been overwritten. - verify-force: - name: Verify (force) - runs-on: ubuntu-latest - needs: - - get-token - - create-branch-tag - - create-conflict - - sync-force - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Decrypt GitHub App token - id: decrypt-token - uses: ./decrypt-token - with: - token: ${{ needs.get-token.outputs.enc_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} - - - name: Clone the destination repo - uses: ./clone-repo - with: - repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} - token: ${{ steps.decrypt-token.outputs.token }} - - - name: Verify that the conflict no longer exists in the destination repo - working-directory: ${{ env.REPO_DST }} - env: - COMMIT: ${{ needs.create-conflict.outputs.commit }} - run: | - git checkout ${BRANCH} - if git merge-base --is-ancestor ${COMMIT} HEAD; then - echo "Branch ${BRANCH} contains commit ${COMMIT}, but shouldn't." - exit 1 - fi - # Clean up the test branch and tag from both repositories, and revoke the GitHub App token. Run # the cleanup job even if previous jobs failed, and also run some of the steps unconditionally, so # they also run if previous jobs failed. @@ -342,7 +242,7 @@ jobs: needs: - get-token - create-branch-tag - - verify-force + - verify-regular steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/README.md b/README.md index 81b3a61..a2f0da4 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,6 @@ Reusable workflows and actions for XRPLF repos ## Available Actions - `cleanup-workspace`: Cleans up the GitHub Actions workspace, should be used for self-hosted runners before running any steps. -- `clone-repo`: Clones a repository authenticated with a token, so that subsequent plain git commands work without additional setup. -- `configure-git`: Configures the git user identity (name and email) for the current job. -- `configure-github-app`: Creates a token for a GitHub App and exposes its git user identity, for use with `configure-git`. - `create-issue`: Creates an issue in the repository with a specified title and body. - `decrypt-token`: Base64-decodes and GPG-decrypts a token. - `encrypt-token`: GPG-encrypts and base64-encodes a token. @@ -15,6 +12,12 @@ Reusable workflows and actions for XRPLF repos - `prepare-runner`: Prepares the GitHub Actions runner environment for subsequent steps. - `print-build-env`: Prints environment related to the build process. +## Available Git Actions + +- `clone-repo`: Clones a repository authenticated with a token, so that subsequent plain git commands work without additional setup. +- `configure-git`: Configures the git user identity (name and email) for the current job. +- `configure-github-app`: Creates a token for a GitHub App and exposes its git user identity, for use with `configure-git`. + ## Available Reusable Workflows - `build-multiarch-image.yml`: Builds a multi-architecture Docker image and pushes it to a container registry. From 89388bfc9d460deb6c3499f9199f765fd9104007 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 27 Jul 2026 16:24:22 -0400 Subject: [PATCH 4/9] Address review feedback --- .github/scripts/sync-branches.sh | 7 +-- .github/scripts/sync-common-functions.sh | 13 +++--- .github/workflows/test-clone-repo.yml | 8 ++-- .github/workflows/test-configure-git.yml | 16 +++---- .../workflows/test-configure-github-app.yml | 33 +++++++------- .../workflows/test-encrypt-decrypt-token.yml | 44 ++++++++----------- .github/workflows/test-get-nproc.yml | 44 ++++++++++--------- .github/workflows/test-sync-branches-tags.yml | 36 ++++++--------- configure-git/action.yml | 17 ++++--- 9 files changed, 106 insertions(+), 112 deletions(-) diff --git a/.github/scripts/sync-branches.sh b/.github/scripts/sync-branches.sh index 4dbf4c0..343ec6e 100755 --- a/.github/scripts/sync-branches.sh +++ b/.github/scripts/sync-branches.sh @@ -67,9 +67,10 @@ fetch_branches() { [ -n "$(git for-each-ref "${SOURCE_REF}/")" ] || die "Source branch(es) '${BRANCHES_GLOB}' not found in '${SOURCE_REPO}'." - # A branch that does not yet exist on the destination is expected: it is created below. Such a - # fetch fails, so failures are ignored here; a genuine problem (unreachable remote, invalid - # token) surfaces when pushing. + # A branch that does not yet exist on the destination is expected: it is created below. A literal + # branch name that is missing makes this fetch fail (a glob matching nothing succeeds), so + # failures are ignored here; a genuine problem such as an unreachable remote or an invalid token + # surfaces when pushing. echo "Fetching destination branch(es): ${BRANCHES_GLOB}" git fetch --quiet "${DEST_REMOTE}" \ "+${BRANCH_REF}/${BRANCHES_GLOB}:${DEST_REF}/${BRANCHES_GLOB}" || true diff --git a/.github/scripts/sync-common-functions.sh b/.github/scripts/sync-common-functions.sh index 5a8f5d9..62b7ca8 100644 --- a/.github/scripts/sync-common-functions.sh +++ b/.github/scripts/sync-common-functions.sh @@ -8,8 +8,9 @@ die() { exit 1 } -# Apply the defaults shared by all sync scripts. These match what is set in the workflow file, so -# the scripts can also be run locally. Keep in sync with the workflow file. +# Apply the defaults shared by all sync scripts, so that only the variables that differ from the +# default need to be set. The workflow passes DRY_RUN explicitly; FORCE is only ever set when running +# a script directly, as the workflow deliberately does not expose force-pushing. set_common_defaults() { DRY_RUN="${DRY_RUN:-false}" FORCE="${FORCE:-false}" @@ -30,9 +31,11 @@ push_ref() { # is passed via an HTTP Authorization header rather than embedded in the remote URL. Embedding would # persist the credential in .git/config and, because git prints the remote URL in its error # messages, risk leaking it into CI logs. Passing it as an 'extraheader' keeps git's output limited -# to 'https://github.com/'. This mirrors how actions/checkout authenticates. The 'tr -d' -# command strips any line breaks base64 may insert so the header stays on one line. A public -# repository ignores a valid token, so the same header is used for both. +# to 'https://github.com/'. The header is scoped to the single repository rather than to +# github.com as a whole, so that the token for one remote is never sent to the other. The 'tr -d' +# command strips the line breaks GNU base64 inserts (it wraps at 76 columns, and an encoded token +# exceeds that) so the header stays on one line. A public repository ignores a valid token, so the +# same header is used for both. # Args: add_authenticated_remote() { local remote="$1" repo="$2" diff --git a/.github/workflows/test-clone-repo.yml b/.github/workflows/test-clone-repo.yml index 6e45639..85dcf5d 100644 --- a/.github/workflows/test-clone-repo.yml +++ b/.github/workflows/test-clone-repo.yml @@ -67,8 +67,10 @@ jobs: # A push is what actually proves the persisted credential works: unlike a fetch, which succeeds # anonymously on a public repository, a push is rejected without a token that has write access. - # --dry-run means nothing is written, and the refspec targets the branch that was just cloned, - # so the push is a no-op even if it were to run for real. + # Nothing is written, as --dry-run stops before any ref is updated. The target is a branch name + # that does not exist, so the push is always a fast-forwardable ref creation; pushing to an + # existing branch would instead be rejected as non-fast-forward whenever HEAD is not at its tip, + # which is a failure unrelated to authentication. - name: Confirm a plain git command is authenticated against the clone working-directory: ${{ env.REPO_NAME }} - run: git push --dry-run origin "HEAD:refs/heads/$(git rev-parse --abbrev-ref HEAD)" + run: git push --dry-run origin "HEAD:refs/heads/ci-auth-probe-${GITHUB_RUN_ID}" diff --git a/.github/workflows/test-configure-git.yml b/.github/workflows/test-configure-git.yml index bed531f..7a7f7d6 100644 --- a/.github/workflows/test-configure-git.yml +++ b/.github/workflows/test-configure-git.yml @@ -30,16 +30,12 @@ jobs: user_name: Test User user_email: test-user@example.com - - name: Confirm results + - name: Confirm git user.name was configured env: USER_NAME: Test User + run: test "$(git config --global user.name)" = "${USER_NAME}" + + - name: Confirm git user.email was configured + env: USER_EMAIL: test-user@example.com - run: | - if [[ "$(git config --global user.name)" != "${USER_NAME}" ]]; then - echo "Unexpected git user.name" - exit 1 - fi - if [[ "$(git config --global user.email)" != "${USER_EMAIL}" ]]; then - echo "Unexpected git user.email" - exit 1 - fi + run: test "$(git config --global user.email)" = "${USER_EMAIL}" diff --git a/.github/workflows/test-configure-github-app.yml b/.github/workflows/test-configure-github-app.yml index 774600e..8d22a32 100644 --- a/.github/workflows/test-configure-github-app.yml +++ b/.github/workflows/test-configure-github-app.yml @@ -34,20 +34,21 @@ jobs: private_key: ${{ secrets.XRPLF_GITHUB_APP_PRIVATE_KEY }} repositories: ${{ github.event.repository.name }} - - name: Confirm results + # The outputs are passed via env rather than interpolated into the script, so that the token is + # never substituted into the script body and values are not re-parsed by the shell. + - name: Confirm an app token was returned env: - USER_NAME: xrplf-github-bot[bot] - USER_EMAIL: 217668010+xrplf-github-bot[bot]@users.noreply.github.com - run: | - if [[ -z '${{ steps.app.outputs.app_token }}' ]]; then - echo "Expected app_token to be set" - exit 1 - fi - if [[ '${{ steps.app.outputs.user_name }}' != '${{ env.USER_NAME }}' ]]; then - echo "Unexpected app user_name" - exit 1 - fi - if [[ "${{ steps.app.outputs.user_email }}" != '${{ env.USER_EMAIL }}' ]]; then - echo "Unexpected app user_email" - exit 1 - fi + APP_TOKEN: ${{ steps.app.outputs.app_token }} + run: test -n "${APP_TOKEN}" + + - name: Confirm the app user_name is exposed + env: + ACTUAL: ${{ steps.app.outputs.user_name }} + EXPECTED: xrplf-github-bot[bot] + run: test "${ACTUAL}" = "${EXPECTED}" + + - name: Confirm the app user_email is exposed + env: + ACTUAL: ${{ steps.app.outputs.user_email }} + EXPECTED: 217668010+xrplf-github-bot[bot]@users.noreply.github.com + run: test "${ACTUAL}" = "${EXPECTED}" diff --git a/.github/workflows/test-encrypt-decrypt-token.yml b/.github/workflows/test-encrypt-decrypt-token.yml index bbe7502..5d44c97 100644 --- a/.github/workflows/test-encrypt-decrypt-token.yml +++ b/.github/workflows/test-encrypt-decrypt-token.yml @@ -37,24 +37,20 @@ jobs: token: ${{ env.TEST_TOKEN }} gpg_passphrase: ${{ env.GPG_PASSPHRASE }} - - name: Confirm encryption results + - name: Confirm an encrypted token was returned env: ENC_TOKEN: ${{ steps.encrypt.outputs.token }} - run: | - if [[ -z "${ENC_TOKEN}" ]]; then - echo "Expected token output to be set" - exit 1 - fi - if [[ "${ENC_TOKEN}" == "test-token-value" ]]; then - echo "Expected token to be encrypted, but it was returned unchanged" - exit 1 - fi + run: test -n "${ENC_TOKEN}" - # The output must be valid base64. - if ! echo -n "${ENC_TOKEN}" | base64 -d >/dev/null; then - echo "Expected token to be valid base64" - exit 1 - fi + - name: Confirm the token was not returned unchanged + env: + ENC_TOKEN: ${{ steps.encrypt.outputs.token }} + run: test "${ENC_TOKEN}" != "${TEST_TOKEN}" + + - name: Confirm the encrypted token is valid base64 + env: + ENC_TOKEN: ${{ steps.encrypt.outputs.token }} + run: echo -n "${ENC_TOKEN}" | base64 -d >/dev/null - name: Run decrypt-token with a GPG-encrypted token id: decrypt-encrypted @@ -69,16 +65,12 @@ jobs: with: token: ${{ env.TEST_TOKEN }} - - name: Confirm decryption results + - name: Confirm the decrypted token matches the original env: DECRYPTED: ${{ steps.decrypt-encrypted.outputs.token }} - PASSED_THROUGH: ${{ steps.decrypt-plain.outputs.token }} - run: | - if [[ "${DECRYPTED}" != "${TEST_TOKEN}" ]]; then - echo 'Expected the decrypted token to match the original.' - exit 1 - fi - if [[ "${PASSED_THROUGH}" != "${TEST_TOKEN}" ]]; then - echo 'Expected the passed-through token to match the original.' - exit 1 - fi + run: test "${DECRYPTED}" = "${TEST_TOKEN}" + + - name: Confirm a plain token is passed through unchanged + env: + PLAIN: ${{ steps.decrypt-plain.outputs.token }} + run: test "${PLAIN}" = "${TEST_TOKEN}" diff --git a/.github/workflows/test-get-nproc.yml b/.github/workflows/test-get-nproc.yml index fc7c361..e9f01ba 100644 --- a/.github/workflows/test-get-nproc.yml +++ b/.github/workflows/test-get-nproc.yml @@ -39,21 +39,21 @@ jobs: - name: Checkout Repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Test get-nproc action without subtraction + - name: Run get-nproc action without subtraction id: without-subtraction uses: ./get-nproc - - name: Test get-nproc action with small subtraction + - name: Run get-nproc action with small subtraction id: with-small-subtraction uses: ./get-nproc with: subtract: ${{ env.SUBTRACTION }} - - name: Test get-nproc action with large subtraction (should fail) + - name: Run get-nproc action with large subtraction (should fail) id: with-large-subtraction uses: ./get-nproc with: - subtract: 1000 + subtract: "1000" continue-on-error: true - name: Display results @@ -62,23 +62,25 @@ jobs: WITH_SUB: ${{ steps.with-small-subtraction.outputs.nproc }} run: | echo "NPROC without subtraction: ${WITHOUT_SUB}" - if [ ${WITHOUT_SUB} -lt 1 ]; then - echo "Expected at least 1 processor before subtraction." - exit 1 - fi - echo "NPROC with small subtraction: ${WITH_SUB}" - if [ ${WITH_SUB} -lt 1 ]; then - echo "Expected at least 1 processor after subtraction." - exit 1 - fi - if ((${WITHOUT_SUB} - ${SUBTRACTION} != ${WITH_SUB})); then - echo "Expected ${WITHOUT_SUB}-${SUBTRACTION}=${WITH_SUB}." - exit 1 - fi + - name: Confirm at least 1 processor is reported without subtraction + env: + WITHOUT_SUB: ${{ steps.without-subtraction.outputs.nproc }} + run: test "${WITHOUT_SUB}" -ge 1 + + - name: Confirm at least 1 processor is reported after subtraction + env: + WITH_SUB: ${{ steps.with-small-subtraction.outputs.nproc }} + run: test "${WITH_SUB}" -ge 1 - if [ '${{ steps.with-large-subtraction.outcome }}' != 'failure' ]; then - echo "Expected large subtraction to fail." - exit 1 - fi + - name: Confirm the subtraction was applied to the processor count + env: + WITHOUT_SUB: ${{ steps.without-subtraction.outputs.nproc }} + WITH_SUB: ${{ steps.with-small-subtraction.outputs.nproc }} + run: test "$((WITHOUT_SUB - SUBTRACTION))" -eq "${WITH_SUB}" + + - name: Confirm a subtraction larger than the processor count fails + env: + OUTCOME: ${{ steps.with-large-subtraction.outcome }} + run: test "${OUTCOME}" = "failure" diff --git a/.github/workflows/test-sync-branches-tags.yml b/.github/workflows/test-sync-branches-tags.yml index 141a596..f5e4e68 100644 --- a/.github/workflows/test-sync-branches-tags.yml +++ b/.github/workflows/test-sync-branches-tags.yml @@ -163,19 +163,15 @@ jobs: repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} token: ${{ steps.decrypt-token.outputs.token }} - - name: Verify that the test branch and tag do not exist in the destination repo + # A plain clone only checks out the default branch under refs/heads; every other branch + # (including this test branch) is only present as a remote-tracking ref. + - name: Confirm the test branch was not created by the dry run working-directory: ${{ env.REPO_DST }} - run: | - # A plain clone only checks out the default branch under refs/heads; every other branch - # (including this test branch) is only present as a remote-tracking ref. - if git show-ref --verify --quiet refs/remotes/origin/${BRANCH}; then - echo "Branch ${BRANCH} exists in ${REPO_DST}, but should not." - exit 1 - fi - if git show-ref --verify --quiet refs/tags/${TAG}; then - echo "Tag ${TAG} exists in ${REPO_DST}, but should not." - exit 1 - fi + run: '! git show-ref --verify --quiet "refs/remotes/origin/${BRANCH}"' + + - name: Confirm the test tag was not created by the dry run + working-directory: ${{ env.REPO_DST }} + run: '! git show-ref --verify --quiet "refs/tags/${TAG}"' # Do a regular (non-dry-run) sync, which will create the test branch and tag in the destination # repository. @@ -220,17 +216,13 @@ jobs: repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} token: ${{ steps.decrypt-token.outputs.token }} - - name: Verify that the test branch and tag exist in the destination repo + - name: Confirm the test branch was synchronized to the destination working-directory: ${{ env.REPO_DST }} - run: | - if ! git show-ref --verify --quiet refs/remotes/origin/${BRANCH}; then - echo "Branch ${BRANCH} does not exist in ${REPO_DST}, but should." - exit 1 - fi - if ! git show-ref --verify --quiet refs/tags/${TAG}; then - echo "Tag ${TAG} does not exist in ${REPO_DST}, but should." - exit 1 - fi + run: git show-ref --verify --quiet "refs/remotes/origin/${BRANCH}" + + - name: Confirm the test tag was synchronized to the destination + working-directory: ${{ env.REPO_DST }} + run: git show-ref --verify --quiet "refs/tags/${TAG}" # Clean up the test branch and tag from both repositories, and revoke the GitHub App token. Run # the cleanup job even if previous jobs failed, and also run some of the steps unconditionally, so diff --git a/configure-git/action.yml b/configure-git/action.yml index bc9e3ff..9c7cb97 100644 --- a/configure-git/action.yml +++ b/configure-git/action.yml @@ -12,13 +12,18 @@ inputs: runs: using: composite steps: - - name: Configure git + - name: Configure git user name shell: bash env: USER_NAME: ${{ inputs.user_name }} + run: git config --global user.name "${USER_NAME}" + + - name: Configure git user email + shell: bash + env: USER_EMAIL: ${{ inputs.user_email }} - run: | - git config --global user.name "${USER_NAME}" - git config --global user.email "${USER_EMAIL}" - # Disable nagging message. - git config --global advice.defaultBranchName false + run: git config --global user.email "${USER_EMAIL}" + + - name: Disable the default branch name advice + shell: bash + run: git config --global advice.defaultBranchName false From 470b9bc0779604fd0c145f221e5330f7db692f97 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 27 Jul 2026 20:37:55 -0400 Subject: [PATCH 5/9] Get an fresh app token in each job so passing it around isn't needed anymore --- .github/workflows/sync-branches-tags.yml | 83 ++++++++--- .../workflows/test-encrypt-decrypt-token.yml | 76 ---------- .github/workflows/test-sync-branches-tags.yml | 132 ++++++------------ README.md | 2 - configure-github-app/action.yml | 8 +- decrypt-token/action.yml | 33 ----- encrypt-token/action.yml | 28 ---- 7 files changed, 110 insertions(+), 252 deletions(-) delete mode 100644 .github/workflows/test-encrypt-decrypt-token.yml delete mode 100644 decrypt-token/action.yml delete mode 100644 encrypt-token/action.yml diff --git a/.github/workflows/sync-branches-tags.yml b/.github/workflows/sync-branches-tags.yml index caa3dd7..383141e 100644 --- a/.github/workflows/sync-branches-tags.yml +++ b/.github/workflows/sync-branches-tags.yml @@ -1,7 +1,16 @@ # This workflow can be used to synchronize branches and tags between two repositories. Existing # destination branches are fast-forwarded to match the source, and missing branches are created. A -# branch that has diverged from the source is never overwritten; the run fails instead. A dry run can -# be performed to report what would change. Tags are synced after all branch jobs complete. +# branch that has diverged from the source is never overwritten; the run fails instead. A dry run +# can be performed to report what would change. Tags are synced after all branch jobs complete. +# +# Authentication uses a GitHub App: every job mints its own installation token from the App +# credentials, scoped to the source and destination repositories, and that token is revoked when the +# job completes. A token is therefore never passed between jobs, which is not possible anyway, as +# the runner redacts secrets from job outputs rather than forwarding them. +# +# Because an installation token is scoped to a single account, the source and destination must be +# owned by the same user or organization, and the App must be installed on it with access to both +# repositories. Synchronizing repositories across two owners is not supported. name: Synchronize branches and tags between two repositories on: @@ -24,32 +33,35 @@ on: type: boolean default: false secrets: - gh_token: - description: "GitHub token (optionally GPG-encrypted and base64-encoded) to use for authentication, which must have access to read from the source repo and write to the destination repo." + app_client_id: + description: "Client identifier of a GitHub App installed on both the source and destination repositories." + required: true + app_private_key: + description: "Private key of the GitHub App identified by app_client_id." required: true - gpg_passphrase: - description: "GPG passphrase to use for decrypting the token, if it is encrypted." - required: false defaults: run: shell: bash jobs: - # Determine the list of branches to synchronize into a JSON array that the matrix below expands. + # Determine the list of branches to synchronize into a JSON array that the matrix below expands, + # and the owner and repository names the GitHub App token must be scoped to. get-branches: name: Get branches runs-on: ubuntu-latest outputs: branches: ${{ steps.list.outputs.branches }} + owner: ${{ steps.repos.outputs.owner }} + repositories: ${{ steps.repos.outputs.repositories }} steps: - name: Determine branch list id: list env: BRANCHES: ${{ inputs.branches }} + # Convert the comma-separated list into the JSON array the matrix expects. Trim whitespace + # and drop empty entries to avoid matrix jobs with blank branch names. run: | - # Convert the comma-separated list into the JSON array the matrix expects. Trim whitespace - # and drop empty entries to avoid matrix jobs with blank branch names. JSON="$(printf '%s' "${BRANCHES}" | jq --raw-input --compact-output \ 'split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length > 0))')" if [ "${JSON}" = "[]" ]; then @@ -58,6 +70,25 @@ jobs: fi echo "branches=${JSON}" >> "$GITHUB_OUTPUT" + - name: Confirm the source and destination share one owner + env: + SOURCE: ${{ inputs.source }} + DESTINATION: ${{ inputs.destination }} + run: | + if [ "${SOURCE%%/*}" != "${DESTINATION%%/*}" ]; then + echo "::error::Source '${SOURCE}' and destination '${DESTINATION}' must have the same owner." + exit 1 + fi + + - name: Determine the owner and repositories to scope the token to + id: repos + env: + SOURCE: ${{ inputs.source }} + DESTINATION: ${{ inputs.destination }} + run: | + echo "owner=${SOURCE%%/*}" >> "$GITHUB_OUTPUT" + echo "repositories=${SOURCE#*/},${DESTINATION#*/}" >> "$GITHUB_OUTPUT" + # Sync each branch in its own job so they can run in parallel. sync-branch: name: Synchronize ${{ matrix.branch }} @@ -76,16 +107,18 @@ jobs: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} - - name: Decrypt token - id: decrypt-token - uses: ./decrypt-token + - name: Configure GitHub App + id: configure-app + uses: ./configure-github-app with: - token: ${{ secrets.gh_token }} - gpg_passphrase: ${{ secrets.gpg_passphrase }} + client_id: ${{ secrets.app_client_id }} + private_key: ${{ secrets.app_private_key }} + owner: ${{ needs.get-branches.outputs.owner }} + repositories: ${{ needs.get-branches.outputs.repositories }} - name: Sync branch env: - GH_TOKEN: ${{ steps.decrypt-token.outputs.token }} + GH_TOKEN: ${{ steps.configure-app.outputs.app_token }} BRANCHES_GLOB: ${{ matrix.branch }} DRY_RUN: ${{ inputs.dry_run }} SOURCE_REPO: ${{ inputs.source }} @@ -96,7 +129,9 @@ jobs: # a branch in the destination repository before the tag pointing to them arrives. sync-tags: name: Synchronize tags - needs: sync-branch + needs: + - get-branches + - sync-branch runs-on: ubuntu-latest steps: # Explicitly check out the current repository so we can reference the local files. @@ -106,16 +141,18 @@ jobs: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} - - name: Decrypt token - id: decrypt-token - uses: ./decrypt-token + - name: Configure GitHub App + id: configure-app + uses: ./configure-github-app with: - token: ${{ secrets.gh_token }} - gpg_passphrase: ${{ secrets.gpg_passphrase }} + client_id: ${{ secrets.app_client_id }} + private_key: ${{ secrets.app_private_key }} + owner: ${{ needs.get-branches.outputs.owner }} + repositories: ${{ needs.get-branches.outputs.repositories }} - name: Sync tags env: - GH_TOKEN: ${{ steps.decrypt-token.outputs.token }} + GH_TOKEN: ${{ steps.configure-app.outputs.app_token }} DRY_RUN: ${{ inputs.dry_run }} SOURCE_REPO: ${{ inputs.source }} DEST_REPO: ${{ inputs.destination }} diff --git a/.github/workflows/test-encrypt-decrypt-token.yml b/.github/workflows/test-encrypt-decrypt-token.yml deleted file mode 100644 index 5d44c97..0000000 --- a/.github/workflows/test-encrypt-decrypt-token.yml +++ /dev/null @@ -1,76 +0,0 @@ -on: - pull_request: - paths: - - ".github/workflows/test-encrypt-decrypt-token.yml" - - "encrypt-token/**" - - "decrypt-token/**" - push: - branches: [main] - paths: - - ".github/workflows/test-encrypt-decrypt-token.yml" - - "encrypt-token/**" - - "decrypt-token/**" - workflow_dispatch: - schedule: - - cron: "0 8 * * *" - -defaults: - run: - shell: bash - -env: - GPG_PASSPHRASE: test-passphrase - TEST_TOKEN: test-token-value - -jobs: - test-encrypt-decrypt-token: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Run encrypt-token - id: encrypt - uses: ./encrypt-token - with: - token: ${{ env.TEST_TOKEN }} - gpg_passphrase: ${{ env.GPG_PASSPHRASE }} - - - name: Confirm an encrypted token was returned - env: - ENC_TOKEN: ${{ steps.encrypt.outputs.token }} - run: test -n "${ENC_TOKEN}" - - - name: Confirm the token was not returned unchanged - env: - ENC_TOKEN: ${{ steps.encrypt.outputs.token }} - run: test "${ENC_TOKEN}" != "${TEST_TOKEN}" - - - name: Confirm the encrypted token is valid base64 - env: - ENC_TOKEN: ${{ steps.encrypt.outputs.token }} - run: echo -n "${ENC_TOKEN}" | base64 -d >/dev/null - - - name: Run decrypt-token with a GPG-encrypted token - id: decrypt-encrypted - uses: ./decrypt-token - with: - token: ${{ steps.encrypt.outputs.token }} - gpg_passphrase: ${{ env.GPG_PASSPHRASE }} - - - name: Run decrypt-token with a plain (unencrypted) token - id: decrypt-plain - uses: ./decrypt-token - with: - token: ${{ env.TEST_TOKEN }} - - - name: Confirm the decrypted token matches the original - env: - DECRYPTED: ${{ steps.decrypt-encrypted.outputs.token }} - run: test "${DECRYPTED}" = "${TEST_TOKEN}" - - - name: Confirm a plain token is passed through unchanged - env: - PLAIN: ${{ steps.decrypt-plain.outputs.token }} - run: test "${PLAIN}" = "${TEST_TOKEN}" diff --git a/.github/workflows/test-sync-branches-tags.yml b/.github/workflows/test-sync-branches-tags.yml index f5e4e68..6cb14c8 100644 --- a/.github/workflows/test-sync-branches-tags.yml +++ b/.github/workflows/test-sync-branches-tags.yml @@ -9,8 +9,6 @@ on: - "clone-repo/**" - "configure-git/**" - "configure-github-app/**" - - "decrypt-token/**" - - "encrypt-token/**" push: branches: [main] paths: @@ -20,8 +18,6 @@ on: - "clone-repo/**" - "configure-git/**" - "configure-github-app/**" - - "decrypt-token/**" - - "encrypt-token/**" workflow_dispatch: schedule: - cron: "0 9 * * *" @@ -46,11 +42,19 @@ env: TAG: v${{ github.run_number }}.${{ github.run_attempt }} jobs: - # Configure the GitHub App token to use for authentication, and GPG-encrypt and base64-encode the - # token so it can be passed to subsequent jobs. - get-token: - name: Get GitHub App token + # Create the test branch and tag in the source repository. Every job mints its own GitHub App + # token, scoped to the test repositories and revoked when the job ends, so no token is passed + # between jobs. + create-branch-tag: + name: Create branch and tag runs-on: ubuntu-latest + outputs: + # The env context is not available in a job's `with:` when calling a reusable workflow, so + # expose these as job outputs for the sync-* jobs below to consume via `needs`. (Note that, in + # contrast, the env context is available in a step's `with:` when calling a local action.) + repo_src: ${{ env.REPO_SRC }} + repo_dst: ${{ env.REPO_DST }} + branch: ${{ env.BRANCH }} steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -62,49 +66,18 @@ jobs: client_id: ${{ secrets.XRPLF_GITHUB_APP_CLIENT_ID }} private_key: ${{ secrets.XRPLF_GITHUB_APP_PRIVATE_KEY }} repositories: ${{ env.REPO_SRC }},${{ env.REPO_DST }} - # The token is passed to and used by later jobs in this workflow, so it must not be - # revoked when this job completes. - skip_token_revoke: true - - - name: Encrypt GitHub App token - id: encrypt-token - uses: ./encrypt-token - with: - token: ${{ steps.configure-app.outputs.app_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} - outputs: - enc_token: ${{ steps.encrypt-token.outputs.token }} - # The username and email are needed to configure git for each commit. - user_name: ${{ steps.configure-app.outputs.user_name }} - user_email: ${{ steps.configure-app.outputs.user_email }} - - # Create the test branch and tag in the source repository. - create-branch-tag: - name: Create branch and tag - runs-on: ubuntu-latest - needs: get-token - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Decrypt GitHub App token - id: decrypt-token - uses: ./decrypt-token - with: - token: ${{ needs.get-token.outputs.enc_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} - name: Configure git uses: ./configure-git with: - user_name: ${{ needs.get-token.outputs.user_name }} - user_email: ${{ needs.get-token.outputs.user_email }} + user_name: ${{ steps.configure-app.outputs.user_name }} + user_email: ${{ steps.configure-app.outputs.user_email }} - name: Clone the source repo uses: ./clone-repo with: repository: ${{ github.repository_owner }}/${{ env.REPO_SRC }} - token: ${{ steps.decrypt-token.outputs.token }} + token: ${{ steps.configure-app.outputs.app_token }} - name: Create a test branch and tag in the source repo working-directory: ${{ env.REPO_SRC }} @@ -114,24 +87,15 @@ jobs: git push --set-upstream origin ${BRANCH} git tag ${TAG} git push origin tag ${TAG} - outputs: - # The env context is not available in a job's `with:` when calling a reusable workflow, so - # expose these as job outputs for the sync-* jobs below to consume via `needs`. (Note that, in - # contrast, the env context is available in a step's `with:` when calling a local action.) - repo_src: ${{ env.REPO_SRC }} - repo_dst: ${{ env.REPO_DST }} - branch: ${{ env.BRANCH }} # Do a sync dry-run to ensure the workflow and scripts are working. sync-dry-run: name: Sync branches and tags (dry-run) - needs: - - get-token - - create-branch-tag + needs: create-branch-tag uses: ./.github/workflows/sync-branches-tags.yml secrets: - gh_token: ${{ needs.get-token.outputs.enc_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + app_client_id: ${{ secrets.XRPLF_GITHUB_APP_CLIENT_ID }} + app_private_key: ${{ secrets.XRPLF_GITHUB_APP_PRIVATE_KEY }} with: source: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_src }} destination: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_dst }} @@ -143,25 +107,25 @@ jobs: name: Verify (dry-run) runs-on: ubuntu-latest needs: - - get-token - create-branch-tag - sync-dry-run steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Decrypt GitHub App token - id: decrypt-token - uses: ./decrypt-token + - name: Configure GitHub App + id: configure-app + uses: ./configure-github-app with: - token: ${{ needs.get-token.outputs.enc_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + client_id: ${{ secrets.XRPLF_GITHUB_APP_CLIENT_ID }} + private_key: ${{ secrets.XRPLF_GITHUB_APP_PRIVATE_KEY }} + repositories: ${{ env.REPO_DST }} - name: Clone the destination repo uses: ./clone-repo with: repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} - token: ${{ steps.decrypt-token.outputs.token }} + token: ${{ steps.configure-app.outputs.app_token }} # A plain clone only checks out the default branch under refs/heads; every other branch # (including this test branch) is only present as a remote-tracking ref. @@ -179,12 +143,11 @@ jobs: name: Sync branches and tags (regular) uses: ./.github/workflows/sync-branches-tags.yml needs: - - get-token - create-branch-tag - verify-dry-run secrets: - gh_token: ${{ needs.get-token.outputs.enc_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + app_client_id: ${{ secrets.XRPLF_GITHUB_APP_CLIENT_ID }} + app_private_key: ${{ secrets.XRPLF_GITHUB_APP_PRIVATE_KEY }} with: source: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_src }} destination: ${{ github.repository_owner }}/${{ needs.create-branch-tag.outputs.repo_dst }} @@ -196,25 +159,25 @@ jobs: name: Verify (regular) runs-on: ubuntu-latest needs: - - get-token - create-branch-tag - sync-regular steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Decrypt GitHub App token - id: decrypt-token - uses: ./decrypt-token + - name: Configure GitHub App + id: configure-app + uses: ./configure-github-app with: - token: ${{ needs.get-token.outputs.enc_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + client_id: ${{ secrets.XRPLF_GITHUB_APP_CLIENT_ID }} + private_key: ${{ secrets.XRPLF_GITHUB_APP_PRIVATE_KEY }} + repositories: ${{ env.REPO_DST }} - name: Clone the destination repo uses: ./clone-repo with: repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} - token: ${{ steps.decrypt-token.outputs.token }} + token: ${{ steps.configure-app.outputs.app_token }} - name: Confirm the test branch was synchronized to the destination working-directory: ${{ env.REPO_DST }} @@ -224,34 +187,33 @@ jobs: working-directory: ${{ env.REPO_DST }} run: git show-ref --verify --quiet "refs/tags/${TAG}" - # Clean up the test branch and tag from both repositories, and revoke the GitHub App token. Run - # the cleanup job even if previous jobs failed, and also run some of the steps unconditionally, so - # they also run if previous jobs failed. + # Clean up the test branch and tag from both repositories. Run the cleanup job even if previous + # jobs failed, and also run its steps unconditionally, so they run if previous steps failed. clean-up: name: Clean up runs-on: ubuntu-latest if: always() needs: - - get-token - create-branch-tag - verify-regular steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Decrypt GitHub App token - id: decrypt-token - uses: ./decrypt-token + - name: Configure GitHub App + id: configure-app + uses: ./configure-github-app with: - token: ${{ needs.get-token.outputs.enc_token }} - gpg_passphrase: ${{ secrets.TEST_SYNC_BRANCHES_TAGS_GPG_PASSPHRASE }} + client_id: ${{ secrets.XRPLF_GITHUB_APP_CLIENT_ID }} + private_key: ${{ secrets.XRPLF_GITHUB_APP_PRIVATE_KEY }} + repositories: ${{ env.REPO_SRC }},${{ env.REPO_DST }} - name: Clone the source repo if: always() uses: ./clone-repo with: repository: ${{ github.repository_owner }}/${{ env.REPO_SRC }} - token: ${{ steps.decrypt-token.outputs.token }} + token: ${{ steps.configure-app.outputs.app_token }} - name: Remove branch and tag from the source repository if: always() @@ -265,7 +227,7 @@ jobs: uses: ./clone-repo with: repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} - token: ${{ steps.decrypt-token.outputs.token }} + token: ${{ steps.configure-app.outputs.app_token }} - name: Remove branch and tag from the destination repository if: always() @@ -273,9 +235,3 @@ jobs: run: | git push origin --delete ${BRANCH} git push origin --delete tag ${TAG} - - - name: Revoke GitHub App token - if: always() - env: - GH_TOKEN: ${{ steps.decrypt-token.outputs.token }} - run: gh api -X DELETE /installation/token diff --git a/README.md b/README.md index a2f0da4..ad5d06d 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ Reusable workflows and actions for XRPLF repos - `cleanup-workspace`: Cleans up the GitHub Actions workspace, should be used for self-hosted runners before running any steps. - `create-issue`: Creates an issue in the repository with a specified title and body. -- `decrypt-token`: Base64-decodes and GPG-decrypts a token. -- `encrypt-token`: GPG-encrypts and base64-encodes a token. - `get-nproc`: Retrieves the number of processing units available on the runner. - `prepare-runner`: Prepares the GitHub Actions runner environment for subsequent steps. - `print-build-env`: Prints environment related to the build process. diff --git a/configure-github-app/action.yml b/configure-github-app/action.yml index 995d0bf..e7547a2 100644 --- a/configure-github-app/action.yml +++ b/configure-github-app/action.yml @@ -9,8 +9,12 @@ inputs: description: The private key of the GitHub App required: true repositories: - description: One or more comma-separated repositories to create the token for + description: One or more comma-separated repositories to create the token for, as bare names without the owner prefix required: true + owner: + description: The owner of the repositories, which must be the account the GitHub App is installed on (defaults to the owner of the current repository) + required: false + default: ${{ github.repository_owner }} skip_token_revoke: description: If true, the token will not be revoked when the current job completes (set this when the token is passed to and used by other jobs) required: false @@ -36,7 +40,7 @@ runs: with: client-id: ${{ inputs.client_id }} private-key: ${{ inputs.private_key }} - owner: ${{ github.repository_owner }} + owner: ${{ inputs.owner }} repositories: ${{ inputs.repositories }} skip-token-revoke: ${{ inputs.skip_token_revoke }} diff --git a/decrypt-token/action.yml b/decrypt-token/action.yml deleted file mode 100644 index e758b48..0000000 --- a/decrypt-token/action.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Decrypt token -description: Base64-decodes and GPG-decrypts a token if a GPG passphrase is provided, otherwise passes the token through unchanged - -inputs: - token: - description: The base64-encoded, optionally GPG-encrypted token - required: true - gpg_passphrase: - description: The GPG passphrase the token was encrypted with (if not provided, the token is passed through unchanged) - required: false - -outputs: - token: - description: The decrypted (or passed-through) token - value: ${{ steps.decrypt.outputs.token }} - -runs: - using: composite - steps: - - name: Decrypt token - id: decrypt - shell: bash - env: - ENC_TOKEN: ${{ inputs.token }} - GPG_PASSPHRASE: ${{ inputs.gpg_passphrase }} - run: | - if [ -n "${GPG_PASSPHRASE}" ]; then - TOKEN=$(echo -n "${ENC_TOKEN}" | base64 -d | gpg --decrypt --batch --passphrase "${GPG_PASSPHRASE}") - else - TOKEN="${ENC_TOKEN}" - fi - echo "::add-mask::${TOKEN}" - echo "token=${TOKEN}" >> "${GITHUB_OUTPUT}" diff --git a/encrypt-token/action.yml b/encrypt-token/action.yml deleted file mode 100644 index 4ca7041..0000000 --- a/encrypt-token/action.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Encrypt token -description: GPG-encrypt and base64-encode a token - -inputs: - token: - description: The token to encrypt - required: true - gpg_passphrase: - description: The GPG passphrase to encrypt the token with - required: true - -outputs: - token: - description: The base64-encoded, GPG-encrypted token - value: ${{ steps.encrypt.outputs.token }} - -runs: - using: composite - steps: - - name: Encrypt token - id: encrypt - shell: bash - env: - TOKEN: ${{ inputs.token }} - GPG_PASSPHRASE: ${{ inputs.gpg_passphrase }} - run: | - ENC_TOKEN=$(gpg --symmetric --batch --passphrase "${GPG_PASSPHRASE}" --output - <(echo -n "${TOKEN}") | base64 -w0) - echo "token=${ENC_TOKEN}" >> "${GITHUB_OUTPUT}" From ee02f97b04745f98213faff68338342b19e6f179 Mon Sep 17 00:00:00 2001 From: Bart Date: Tue, 28 Jul 2026 17:15:25 -0400 Subject: [PATCH 6/9] Address review feedback --- .github/workflows/sync-branches-tags.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-branches-tags.yml b/.github/workflows/sync-branches-tags.yml index 27db10d..9d0e30b 100644 --- a/.github/workflows/sync-branches-tags.yml +++ b/.github/workflows/sync-branches-tags.yml @@ -89,8 +89,8 @@ jobs: echo "owner=${SOURCE%%/*}" >> "$GITHUB_OUTPUT" echo "repositories=${SOURCE#*/},${DESTINATION#*/}" >> "$GITHUB_OUTPUT" - # Sync each branch in its own job so they can run in parallel. - sync-branch: + # Sync each branch glob in its own job so they can run in parallel. + sync-branch-glob: name: Synchronize ${{ matrix.branch_glob }} needs: get-branch-globs runs-on: ubuntu-latest From 3d7dc732ef00033d557688f9bf42572b1129a2be Mon Sep 17 00:00:00 2001 From: Bart Date: Tue, 28 Jul 2026 17:16:39 -0400 Subject: [PATCH 7/9] Address review feedback --- .github/workflows/sync-branches-tags.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-branches-tags.yml b/.github/workflows/sync-branches-tags.yml index 9d0e30b..e9bded6 100644 --- a/.github/workflows/sync-branches-tags.yml +++ b/.github/workflows/sync-branches-tags.yml @@ -131,7 +131,7 @@ jobs: name: Synchronize tags needs: - get-branch-globs - - sync-branch + - sync-branch-glob runs-on: ubuntu-latest steps: # Explicitly check out the current repository so we can reference the local files. From 272bf7c10e66144b966de02cfd756e535db2d533 Mon Sep 17 00:00:00 2001 From: Bart Date: Wed, 29 Jul 2026 11:15:29 -0400 Subject: [PATCH 8/9] Gate on XRPLF repo --- .github/workflows/test-sync-branches-tags.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-sync-branches-tags.yml b/.github/workflows/test-sync-branches-tags.yml index d25a342..1788975 100644 --- a/.github/workflows/test-sync-branches-tags.yml +++ b/.github/workflows/test-sync-branches-tags.yml @@ -47,6 +47,7 @@ jobs: # between jobs. create-branch-tag: name: Create branch and tag + if: ${{ github.repository == 'XRPLF/actions' }} runs-on: ubuntu-latest outputs: # The env context is not available in a job's `with:` when calling a reusable workflow, so @@ -91,6 +92,7 @@ jobs: # Do a sync dry-run to ensure the workflow and scripts are working. sync-dry-run: name: Sync branches and tags (dry-run) + if: ${{ github.repository == 'XRPLF/actions' }} needs: create-branch-tag uses: ./.github/workflows/sync-branches-tags.yml secrets: @@ -105,6 +107,7 @@ jobs: # Verify that the test branch and tag do not exist in the destination repo. verify-dry-run: name: Verify (dry-run) + if: ${{ github.repository == 'XRPLF/actions' }} runs-on: ubuntu-latest needs: - create-branch-tag @@ -141,6 +144,7 @@ jobs: # repository. sync-regular: name: Sync branches and tags (regular) + if: ${{ github.repository == 'XRPLF/actions' }} uses: ./.github/workflows/sync-branches-tags.yml needs: - create-branch-tag @@ -157,6 +161,7 @@ jobs: # Verify that the test branch and tag now exist in the destination repository. verify-regular: name: Verify (regular) + if: ${{ github.repository == 'XRPLF/actions' }} runs-on: ubuntu-latest needs: - create-branch-tag @@ -191,8 +196,8 @@ jobs: # jobs failed, and also run its steps unconditionally, so they run if previous steps failed. clean-up: name: Clean up + if: ${{ github.repository == 'XRPLF/actions' && always() }} runs-on: ubuntu-latest - if: always() needs: - create-branch-tag - verify-regular From 1f20bc198ddfa95c34c635edf03fb4d46af2cf43 Mon Sep 17 00:00:00 2001 From: Bart Date: Wed, 29 Jul 2026 17:40:24 -0400 Subject: [PATCH 9/9] Change logic to not run on fork PRs --- .github/workflows/test-sync-branches-tags.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-sync-branches-tags.yml b/.github/workflows/test-sync-branches-tags.yml index 1788975..c3cc883 100644 --- a/.github/workflows/test-sync-branches-tags.yml +++ b/.github/workflows/test-sync-branches-tags.yml @@ -47,7 +47,7 @@ jobs: # between jobs. create-branch-tag: name: Create branch and tag - if: ${{ github.repository == 'XRPLF/actions' }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest outputs: # The env context is not available in a job's `with:` when calling a reusable workflow, so @@ -92,7 +92,7 @@ jobs: # Do a sync dry-run to ensure the workflow and scripts are working. sync-dry-run: name: Sync branches and tags (dry-run) - if: ${{ github.repository == 'XRPLF/actions' }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} needs: create-branch-tag uses: ./.github/workflows/sync-branches-tags.yml secrets: @@ -107,7 +107,7 @@ jobs: # Verify that the test branch and tag do not exist in the destination repo. verify-dry-run: name: Verify (dry-run) - if: ${{ github.repository == 'XRPLF/actions' }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest needs: - create-branch-tag @@ -144,7 +144,7 @@ jobs: # repository. sync-regular: name: Sync branches and tags (regular) - if: ${{ github.repository == 'XRPLF/actions' }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} uses: ./.github/workflows/sync-branches-tags.yml needs: - create-branch-tag @@ -161,7 +161,7 @@ jobs: # Verify that the test branch and tag now exist in the destination repository. verify-regular: name: Verify (regular) - if: ${{ github.repository == 'XRPLF/actions' }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest needs: - create-branch-tag @@ -196,7 +196,7 @@ jobs: # jobs failed, and also run its steps unconditionally, so they run if previous steps failed. clean-up: name: Clean up - if: ${{ github.repository == 'XRPLF/actions' && always() }} + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-latest needs: - create-branch-tag