diff --git a/.github/scripts/sync-branches.sh b/.github/scripts/sync-branches.sh new file mode 100755 index 0000000..0dd3c88 --- /dev/null +++ b/.github/scripts/sync-branches.sh @@ -0,0 +1,134 @@ +#!/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 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_GLOB Glob matching the branch(es) 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) +# +# Exit status: +# 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 + +# shellcheck source=sync-common-functions.sh +source "${BASH_SOURCE[0]%/*}/sync-common-functions.sh" + +# --- Configuration ----------------------------------------------------------------------------- + +set_common_defaults +BRANCH_GLOB="${BRANCH_GLOB:-}" + +# 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_config() { + validate_common_config + [ -n "${BRANCH_GLOB}" ] || die "BRANCH_GLOB must be a non-empty branch name or glob." +} + +# 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() { + echo "Fetching source branch(es): ${BRANCH_GLOB}" + git fetch --quiet "${SOURCE_REMOTE}" \ + "+${BRANCH_REF}/${BRANCH_GLOB}:${SOURCE_REF}/${BRANCH_GLOB}" + + # A glob that matches no refs makes 'git fetch' succeed having fetched nothing, so a mistyped + # BRANCH_GLOB would otherwise go undetected. + [ -n "$(git for-each-ref "${SOURCE_REF}/")" ] || + die "Source branch(es) '${BRANCH_GLOB}' not found in '${SOURCE_REPO}'." + + # 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): ${BRANCH_GLOB}" + git fetch --quiet "${DEST_REMOTE}" \ + "+${BRANCH_REF}/${BRANCH_GLOB}:${DEST_REF}/${BRANCH_GLOB}" || true +} + +# 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 push is rejected rather than + # clobbering the destination. + if ! git show-ref --verify --quiet "${DEST_REF}/${ref}"; then + echo "Creating: ${ref}" + 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 [ "${FORCE}" = "true" ]; then + echo "Overwriting: ${ref}" + push_ref "${ref}" --force-with-lease + return + fi + + # Check out the destination branch and fast-forward it to the source. + echo "Syncing: ${ref}" + git checkout --quiet -B "${ref}" "${DEST_REF}/${ref}" + 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). +sync_branches() { + # 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 + + local ref + while IFS= read -r ref; do + sync_ref "${ref}" + done < <(git for-each-ref --format='%(refname:lstrip=3)' "${SOURCE_REF}/") +} + +# --- 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 100644 index 0000000..62b7ca8 --- /dev/null +++ b/.github/scripts/sync-common-functions.sh @@ -0,0 +1,76 @@ +# shellcheck shell=bash +# +# Common helpers shared by sync-branches.sh and sync-tags.sh. + +# Emit an error and exit. +die() { + echo "::error::$1" >&2 + exit 1 +} + +# 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}" +} + +# 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/'. 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" + 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')" +} + +# Validate configuration shared by all sync scripts. +validate_common_config() { + [ -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}'." ;; + esac + + case "${FORCE}" in + true | false) ;; + *) die "FORCE must be 'true' or 'false', got '${FORCE}'." ;; + esac +} + +# Initialize a fresh working repository in a temporary directory and wire up the authenticated +# remotes. +setup_working_repo() { + 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. + 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..6ca9006 --- /dev/null +++ b/.github/scripts/sync-tags.sh @@ -0,0 +1,121 @@ +#!/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, 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 +# 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) +# +# Exit status: +# 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 + +# 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 ----------------------------------------------------------------------------------- + +# 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." + git fetch --no-tags --quiet "${SOURCE_REMOTE}" "+${TAG_REF}/*:${SOURCE_REF}/*" + + echo "Fetching tags from destination." + git fetch --no-tags --quiet "${DEST_REMOTE}" "+${TAG_REF}/*:${DEST_REF}/*" +} + +# 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}" + push_ref "${tag}" + return + 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 + 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}" + push_ref "${tag}" "--force-with-lease=${TAG_REF}/${tag}:${dest_sha}" + return + fi + + die "Tag '${tag}' exists on both sides with different objects; use FORCE to overwrite it." +} + +sync_tags() { + PUSH_OPTS=() + if [ "${DRY_RUN}" = "true" ]; then + echo "Dry run: no tags will be pushed." + PUSH_OPTS+=("--dry-run") + fi + + local tag + while IFS= read -r tag; do + sync_tag "${tag}" + done < <(git for-each-ref --format='%(refname:lstrip=3)' "${SOURCE_REF}/") +} + +# --- Main -------------------------------------------------------------------------------------- + +main() { + validate_common_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..e9bded6 --- /dev/null +++ b/.github/workflows/sync-branches-tags.yml @@ -0,0 +1,159 @@ +# 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. +# +# 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: + 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 + branch_globs: + description: "Comma-separated branch 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 + secrets: + 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 + +defaults: + run: + shell: bash + +jobs: + # Determine the list of branch globs 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-branch-globs: + name: Get branch globs + runs-on: ubuntu-latest + outputs: + branch_globs: ${{ steps.globs.outputs.branch_globs }} + owner: ${{ steps.repos.outputs.owner }} + repositories: ${{ steps.repos.outputs.repositories }} + steps: + - name: Determine branch list + id: globs + env: + BRANCH_GLOBS: ${{ inputs.branch_globs }} + # 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: | + JSON="$(printf '%s' "${BRANCH_GLOBS}" | jq --raw-input --compact-output \ + '[splits(",") | gsub("^\\s+|\\s+$";"") | select(length > 0)]')" + if [ "${JSON}" = "[]" ]; then + echo "::error::No branches to synchronize after parsing '${BRANCH_GLOBS}'." + exit 1 + fi + echo "branch_globs=${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 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 + strategy: + # Do not cancel the other branches if one fails; each branch is synchronized independently. + fail-fast: false + matrix: + branch_glob: ${{ fromJSON(needs.get-branch-globs.outputs.branch_globs) }} + 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: Configure GitHub App + id: configure-app + uses: ./configure-github-app + with: + client_id: ${{ secrets.app_client_id }} + private_key: ${{ secrets.app_private_key }} + owner: ${{ needs.get-branch-globs.outputs.owner }} + repositories: ${{ needs.get-branch-globs.outputs.repositories }} + + - name: Sync branch + env: + GH_TOKEN: ${{ steps.configure-app.outputs.app_token }} + BRANCH_GLOB: ${{ matrix.branch_glob }} + DRY_RUN: ${{ inputs.dry_run }} + 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: + - get-branch-globs + - sync-branch-glob + 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: Configure GitHub App + id: configure-app + uses: ./configure-github-app + with: + client_id: ${{ secrets.app_client_id }} + private_key: ${{ secrets.app_private_key }} + owner: ${{ needs.get-branch-globs.outputs.owner }} + repositories: ${{ needs.get-branch-globs.outputs.repositories }} + + - name: Sync tags + env: + GH_TOKEN: ${{ steps.configure-app.outputs.app_token }} + DRY_RUN: ${{ inputs.dry_run }} + 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..85dcf5d --- /dev/null +++ b/.github/workflows/test-clone-repo.yml @@ -0,0 +1,76 @@ +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 + + # 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 + + # 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 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. + # 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/ci-auth-probe-${GITHUB_RUN_ID}" diff --git a/.github/workflows/test-configure-git.yml b/.github/workflows/test-configure-git.yml new file mode 100644 index 0000000..7a7f7d6 --- /dev/null +++ b/.github/workflows/test-configure-git.yml @@ -0,0 +1,41 @@ +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: Run configure-git + uses: ./configure-git + with: + user_name: Test User + user_email: test-user@example.com + + - 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: 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 80b16a1..7d0f10f 100644 --- a/.github/workflows/test-configure-github-app.yml +++ b/.github/workflows/test-configure-github-app.yml @@ -39,7 +39,7 @@ jobs: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Test configure-github-app + - name: Run configure-github-app id: app uses: ./configure-github-app with: @@ -47,28 +47,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 - 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 + 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-get-nproc.yml b/.github/workflows/test-get-nproc.yml index 66a19fc..185c55c 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - 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 new file mode 100644 index 0000000..c3cc883 --- /dev/null +++ b/.github/workflows/test-sync-branches-tags.yml @@ -0,0 +1,242 @@ +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/**" + 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/**" + 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: + # 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 + 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 + # 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 + + - 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 }} + + - name: Configure git + uses: ./configure-git + with: + 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.configure-app.outputs.app_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} + + # 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.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: + 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 }} + branch_globs: ${{ needs.create-branch-tag.outputs.branch }} + dry_run: true + + # Verify that the test branch and tag do not exist in the destination repo. + verify-dry-run: + name: Verify (dry-run) + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + needs: + - create-branch-tag + - sync-dry-run + 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_DST }} + + - name: Clone the destination repo + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} + 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. + - name: Confirm the test branch was not created by the dry run + working-directory: ${{ env.REPO_DST }} + 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. + sync-regular: + name: Sync branches and tags (regular) + 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 + - verify-dry-run + secrets: + 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 }} + branch_globs: ${{ needs.create-branch-tag.outputs.branch }} + dry_run: false + + # Verify that the test branch and tag now exist in the destination repository. + verify-regular: + name: Verify (regular) + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + needs: + - create-branch-tag + - sync-regular + 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_DST }} + + - name: Clone the destination repo + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_DST }} + token: ${{ steps.configure-app.outputs.app_token }} + + - name: Confirm the test branch was synchronized to the destination + working-directory: ${{ env.REPO_DST }} + 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. 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 + 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 + - verify-regular + 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 }} + + - name: Clone the source repo + if: always() + uses: ./clone-repo + with: + repository: ${{ github.repository_owner }}/${{ env.REPO_SRC }} + token: ${{ steps.configure-app.outputs.app_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.configure-app.outputs.app_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} diff --git a/README.md b/README.md index c36e49a..1951419 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,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. @@ -18,6 +24,7 @@ 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. +- `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..9c7cb97 --- /dev/null +++ b/configure-git/action.yml @@ -0,0 +1,29 @@ +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 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.email "${USER_EMAIL}" + + - name: Disable the default branch name advice + shell: bash + run: git config --global advice.defaultBranchName false diff --git a/configure-github-app/action.yml b/configure-github-app/action.yml index 73df837..e7547a2 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: @@ -9,19 +9,27 @@ 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 + 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 @@ -32,8 +40,9 @@ 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 }} - name: Get GitHub App User ID id: app-user @@ -44,16 +53,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