Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/actions/gh-pages-deploy/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: 'Deploy to GitHub Pages'
description: >
Publish a local directory to a GitHub Pages branch using git worktree and
rsync. Drop-in composite-action replacement for peaceiris/actions-gh-pages,
maintaining the same core inputs.

# Usage example:
#
# - uses: actions/checkout@...
# with:
# fetch-depth: 0
#
# - uses: ./.github/actions/gh-pages-deploy
# with:
# github_token: ${{ secrets.GITHUB_TOKEN }}
# publish_dir: ./dist
# destination_dir: ''
# exclude_assets: |
# .github
# node_modules

inputs:
github_token:
description: >
GitHub token used to authenticate git push. When provided, the action
rewrites the origin remote URL to embed the token so push works without
relying on credentials configured by actions/checkout.
required: true

publish_branch:
description: 'Target branch to deploy to.'
required: false
default: 'gh-pages'

publish_dir:
description: >
Local directory whose contents are published to the branch.
required: false
default: 'public'

destination_dir:
description: >
Subdirectory within publish_branch to deploy into.
Leave empty to deploy to the branch root.
required: false
default: ''

keep_files:
description: >
When "true", preserve existing files in the destination directory that
are not present in publish_dir.
When "false" (default), the destination directory is cleared before
syncing — only applies to subdirectory deployments (destination_dir set).
Root deployments always preserve other content in the branch (e.g. the
previews/ directory).
required: false
default: 'false'

exclude_assets:
description: >
Newline- or comma-separated list of file/directory names to exclude from
the deployment (passed as --exclude flags to rsync).
required: false
default: '.github'

user_name:
description: 'Git commit author name.'
required: false
default: 'github-actions[bot]'

user_email:
description: 'Git commit author email.'
required: false
default: 'github-actions[bot]@users.noreply.github.com'

commit_message:
description: 'Commit message for the deployment commit.'
required: false
default: 'chore: deploy to gh-pages'

disable_nojekyll:
description: >
Set to "true" to skip creating the .nojekyll file, enabling Jekyll
processing on the published branch.
required: false
default: 'false'

runs:
using: composite
steps:
- name: Deploy to branch
# All inputs are assigned to env vars so the shell script never receives
# user-controlled data via command-line interpolation.
env:
INPUT_GITHUB_TOKEN: ${{ inputs.github_token }}
INPUT_PUBLISH_BRANCH: ${{ inputs.publish_branch }}
INPUT_PUBLISH_DIR: ${{ inputs.publish_dir }}
INPUT_DESTINATION_DIR: ${{ inputs.destination_dir }}
INPUT_KEEP_FILES: ${{ inputs.keep_files }}
INPUT_EXCLUDE_ASSETS: ${{ inputs.exclude_assets }}
INPUT_USER_NAME: ${{ inputs.user_name }}
INPUT_USER_EMAIL: ${{ inputs.user_email }}
INPUT_COMMIT_MESSAGE: ${{ inputs.commit_message }}
INPUT_DISABLE_NOJEKYLL: ${{ inputs.disable_nojekyll }}
run: bash "$GITHUB_ACTION_PATH/deploy.sh"
shell: bash
134 changes: 134 additions & 0 deletions .github/actions/gh-pages-deploy/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# deploy.sh — publishes a local directory to a GitHub Pages branch.
# Part of the gh-pages-deploy composite action.
#
# All configuration is read from environment variables (injection-safe).
# Never interpolate ${{ }} expressions directly into this script.
#
# Environment variables:
# INPUT_GITHUB_TOKEN — GitHub token for authenticating git push
# INPUT_PUBLISH_BRANCH — target branch (default: gh-pages)
# INPUT_PUBLISH_DIR — local source directory (default: public)
# INPUT_DESTINATION_DIR — subdirectory within publish_branch (default: '')
# INPUT_KEEP_FILES — preserve existing files in destination (default: false)
# INPUT_EXCLUDE_ASSETS — newline/comma-separated paths to exclude via rsync
# INPUT_USER_NAME — git commit author name
# INPUT_USER_EMAIL — git commit author email
# INPUT_COMMIT_MESSAGE — commit message
# INPUT_DISABLE_NOJEKYLL— if 'true', skip creating .nojekyll

set -euo pipefail

PUBLISH_BRANCH="${INPUT_PUBLISH_BRANCH:-gh-pages}"
PUBLISH_DIR="${INPUT_PUBLISH_DIR:-public}"
DESTINATION_DIR="${INPUT_DESTINATION_DIR:-}"
KEEP_FILES="${INPUT_KEEP_FILES:-false}"
USER_NAME="${INPUT_USER_NAME:-github-actions[bot]}"
USER_EMAIL="${INPUT_USER_EMAIL:-github-actions[bot]@users.noreply.github.com}"
COMMIT_MESSAGE="${INPUT_COMMIT_MESSAGE:-chore: deploy to ${PUBLISH_BRANCH}}"
DISABLE_NOJEKYLL="${INPUT_DISABLE_NOJEKYLL:-false}"
EXCLUDE_ASSETS="${INPUT_EXCLUDE_ASSETS:-.github}"

WORKTREE_DIR="$(mktemp -d)"

# Ensure the worktree is always removed on exit, even if the script fails midway.
cleanup() {
git worktree remove --force "${WORKTREE_DIR}" 2>/dev/null || rm -rf "${WORKTREE_DIR}"
}
trap cleanup EXIT

git config user.name "${USER_NAME}"
git config user.email "${USER_EMAIL}"

# If a GitHub token is provided, configure the remote URL to use it so that
# git push works even when actions/checkout was not called with persist-credentials.
if [[ -n "${INPUT_GITHUB_TOKEN:-}" ]]; then
REPO_URL=$(git remote get-url origin)
# Convert SSH to HTTPS if needed (git@github.com:owner/repo.git → https://…)
if [[ "${REPO_URL}" == git@github.com:* ]]; then
REPO_URL="${REPO_URL/#git@github.com:/https://github.com/}"
fi
# Inject the token as credentials in the HTTPS URL
AUTHED_URL="${REPO_URL/#https:\/\//https://x-access-token:${INPUT_GITHUB_TOKEN}@}"
git remote set-url origin "${AUTHED_URL}"
# Note: the SSH-to-HTTPS conversion above assumes the standard github.com SSH
# URL format (git@github.com:owner/repo.git). GitHub Enterprise instances with
# custom hostnames will need to set an HTTPS origin URL before calling this action.
fi

# Fetch the existing publish branch (silently skip if it doesn't exist yet)
git fetch origin "${PUBLISH_BRANCH}:refs/remotes/origin/${PUBLISH_BRANCH}" 2>/dev/null || true

# Check out the publish branch into a separate worktree so we can update it
# without leaving the main checkout in a detached-HEAD state.
if git rev-parse --verify "refs/remotes/origin/${PUBLISH_BRANCH}" >/dev/null 2>&1; then
git branch --force "${PUBLISH_BRANCH}" "refs/remotes/origin/${PUBLISH_BRANCH}"
git worktree add "${WORKTREE_DIR}" "${PUBLISH_BRANCH}"
else
# First-ever deploy: create an orphan branch
git worktree add --orphan -b "${PUBLISH_BRANCH}" "${WORKTREE_DIR}"
fi

# Determine the destination path within the worktree
if [[ -n "${DESTINATION_DIR}" ]]; then
DEST="${WORKTREE_DIR}/${DESTINATION_DIR}"
else
DEST="${WORKTREE_DIR}"
fi

mkdir -p "${DEST}"

# When keep_files is false and deploying to a subdirectory, pre-clear the
# destination so deleted files don't linger before rsync runs.
if [[ "${KEEP_FILES}" != "true" && -n "${DESTINATION_DIR}" ]]; then
# Suppress errors (|| true): a non-zero exit here means DEST is already empty
# or a file is already gone — both are harmless. Genuine failures (e.g.
# permission errors on the runner) will surface at the rsync or git-add step.
find "${DEST}" -mindepth 1 -delete 2>/dev/null || true
fi

# Build rsync --exclude flags from EXCLUDE_ASSETS.
# Supports both newline-separated and comma-separated values.
RSYNC_EXCLUDES=()
# Always protect the .git worktree reference file so rsync --delete never
# removes it (it only exists in DEST, not in PUBLISH_DIR, so without this
# exclude the --delete flag would wipe it and break subsequent git commands).
RSYNC_EXCLUDES+=("--exclude=.git")
# For root deploys with keep_files=false, protect previews/ so rsync --delete
# doesn't remove preview directories that live alongside the production content.
if [[ -z "${DESTINATION_DIR}" && "${KEEP_FILES}" != "true" ]]; then
RSYNC_EXCLUDES+=("--exclude=previews/")
fi
while IFS= read -r item; do
# Trim leading and trailing whitespace
item="${item#"${item%%[![:space:]]*}"}"
item="${item%"${item##*[![:space:]]}"}"
[[ -n "${item}" ]] && RSYNC_EXCLUDES+=("--exclude=${item}")
done < <(printf '%s\n' "${EXCLUDE_ASSETS}" | tr ',' '\n')

# Sync source into destination.
# Pass --delete when keep_files is false so stale files are removed — for root
# deploys this is the only mechanism (the find-based clear above is skipped).
DELETE_FLAG=()
[[ "${KEEP_FILES}" != "true" ]] && DELETE_FLAG=("--delete")
rsync -a "${DELETE_FLAG[@]}" ${RSYNC_EXCLUDES[@]+"${RSYNC_EXCLUDES[@]}"} "${PUBLISH_DIR%/}/" "${DEST}/"

# Ensure Jekyll processing is disabled after rsync (so rsync --delete cannot
# remove it — we create it after the sync, not before).
if [[ "${DISABLE_NOJEKYLL}" != "true" ]]; then
touch "${WORKTREE_DIR}/.nojekyll"
fi

cd "${WORKTREE_DIR}"
git add -A
if git diff --cached --quiet; then
echo "Nothing to deploy — ${PUBLISH_BRANCH}${DESTINATION_DIR:+/${DESTINATION_DIR}} is already up to date"
else
git commit -m "${COMMIT_MESSAGE}"
git push origin "${PUBLISH_BRANCH}"
echo "Deployed to ${PUBLISH_BRANCH}${DESTINATION_DIR:+/${DESTINATION_DIR}}"
fi

# Return to the main working directory so the EXIT trap's `git worktree remove`
# does not fail with "is the current directory" when the script exits.
cd -
121 changes: 121 additions & 0 deletions .github/actions/paths-filter/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: 'Paths Filter'
description: >
Determine which filter groups have matching changed files by comparing against
a base commit. Drop-in composite-action replacement for dorny/paths-filter,
maintaining the same core inputs and per-filter-group boolean outputs.

# Usage example:
#
# - uses: actions/checkout@...
# with:
# fetch-depth: 0
#
# - uses: ./.github/actions/paths-filter
# id: filter
# with:
# filters: |
# code:
# - '**/*.js'
# - '**/*.ts'
# - 'tests/**'
#
# - if: steps.filter.outputs.code == 'true'
# run: npm test

inputs:
token:
description: >
GitHub token. Accepted for interface compatibility with dorny/paths-filter
but not used internally; authentication is handled by actions/checkout.
required: false
default: ${{ github.token }}

ref:
description: >
Git reference to use as HEAD when computing the diff. Accepted for
interface compatibility; the action always diffs INPUT_SHA (github.sha)
against the computed or explicit base.
required: false
default: ''

base:
description: >
Explicit base commit SHA to compare against. When provided, skips
auto-detection from the event payload. Pass this when the action is
called from inside a workflow_call reusable workflow where
github.event_name is "workflow_call" and auto-detection would be wrong.
required: false
default: ''

filters:
description: >
YAML string defining one or more named filter groups, each containing a
list of glob patterns. Format mirrors dorny/paths-filter:

code:
- '**/*.js'
- '**/*.ts'
- 'tests/**'
required: true

list-files:
description: >
Format for the per-filter <name>_files output.
One of: none | json | csv | shell | escape.
Defaults to none (no file list output).
required: false
default: 'none'

initial-fetch-depth:
description: >
Accepted for interface compatibility with dorny/paths-filter.
This action assumes the repo was already checked out with sufficient
depth (fetch-depth: 0) by the calling workflow.
required: false
default: '10'

event_name:
description: >
The real GitHub event name from the calling workflow.
Required when this action is used inside a workflow_call reusable
workflow because github.event_name is always "workflow_call" there,
not the original triggering event.
When empty, the action reads github.event_name directly, which is
correct for workflows triggered by push/pull_request/merge_group.
required: false
default: ''

outputs:
code:
description: >
"true" if the "code" filter group has any matched files; "false" otherwise.
This output matches the primary filter used in this repository.
value: ${{ steps.run.outputs.code }}

changes:
description: >
JSON object mapping every filter group name to true/false.
Use this to read results for filter groups other than "code".
value: ${{ steps.run.outputs.changes }}

runs:
using: composite
steps:
- name: Run paths filter
id: run
# All GitHub context values are assigned to env vars here in the env: block.
# ${{ }} expressions are evaluated by the GitHub Actions runner before the
# shell starts, so they are never interpreted as shell code. This is the
# injection-safe pattern; contrast with interpolating ${{ }} directly
# inside a run: script where attacker-controlled values could escape quotes.
env:
INPUT_BASE: ${{ inputs.base }}
INPUT_EVENT_NAME: ${{ inputs.event_name || github.event_name }}
GH_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
GH_PUSH_BEFORE: ${{ github.event.before }}
GH_MERGE_BASE_SHA: ${{ github.event.merge_group.base_sha }}
INPUT_SHA: ${{ github.sha }}
INPUT_FILTERS: ${{ inputs.filters }}
INPUT_LIST_FILES: ${{ inputs.list-files }}
run: node "$GITHUB_ACTION_PATH/filter.js"
shell: bash
Loading