Skip to content

Rollout Workflow to Project #2

Rollout Workflow to Project

Rollout Workflow to Project #2

name: Rollout Workflow to Project - Rev 4
on:
workflow_dispatch:
inputs:
target_repo:
description: 'Provide the name of the target repo, e.g. "org/project"'
required: true
source_repo:
description: 'Location of source files and automation trigger'
required: true
default: 'hackforla/automate-the-org'
rollout_branch:
description: 'Branch name created for PR for rollout'
required: false
default: 'automation/rollout-central-workflow'
template_file:
description: 'Template file name (must exist in .github/templates/)'
required: true
default: 'pr_template_add_update.md'
github_app_name:
description: 'GitHub App name/slug'
required: true
default: 'hfla-workflow-rollout'
dry_run:
description: 'Dry-run mode: preview changes without creating PRs'
type: boolean
default: true
permissions:
contents: write
pull-requests: write
jobs:
Rollout-Workflow:
runs-on: ubuntu-latest
steps:
- name: Generate token from GitHub App
id: generate-app-token
run: |
# Prevent partial command failures
set -o pipefail
# Generate JWT for GitHub App authentication
printf "%s" "${{ secrets.HFLA_WORKFLOW_APP_PRIVATE_KEY }}" > key.pem
chmod 600 key.pem
# Ensure key is written correctly
if [[ ! -s key.pem ]]; then
echo "Error: private key is empty or missing"
exit 1
fi
NOW=$(date +%s)
IAT=$((NOW - 60))
EXP=$((NOW + 600))
HEADER='{"alg":"RS256","typ":"JWT"}'
PAYLOAD="{\"iat\":${IAT},\"exp\":${EXP},\"iss\":\"${{ secrets.HFLA_WORKFLOW_APP_ID }}\"}"
encode_url() {
openssl base64 -e | tr '+/' '-_' | tr -d '=\n\r'
}
HEADER_B64=$(echo -n "$HEADER" | encode_url)
PAYLOAD_B64=$(echo -n "$PAYLOAD" | encode_url)
SIGNATURE=$(
printf "%s" "${HEADER_B64}.${PAYLOAD_B64}" \
| openssl dgst -sha256 -sign key.pem \
| encode_url
)
JWT="${HEADER_B64}.${PAYLOAD_B64}.${SIGNATURE}"
echo "::add-mask::$JWT"
# Retrieve installation ID
INSTALLATION_ID=$(curl -s \
-H "Authorization: Bearer ${JWT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/orgs/hackforla/installation" \
| jq -r '.id')
if [[ -z "$INSTALLATION_ID" || "$INSTALLATION_ID" == "null" ]]; then
echo "Error: Could not get installation ID"
echo "Make sure the GitHub App is installed on the organization"
exit 1
fi
echo "Installation ID: $INSTALLATION_ID"
# Exchange JWT for installation access token
TOKEN_RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer ${JWT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/app/installations/${INSTALLATION_ID}/access_tokens")
APP_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.token')
if [[ -z "$APP_TOKEN" || "$APP_TOKEN" == "null" ]]; then
echo "Error: Could not generate access token"
echo "Response: $TOKEN_RESPONSE"
exit 1
fi
# Mask token in logs
echo "::add-mask::$APP_TOKEN"
echo "token=$APP_TOKEN" >> $GITHUB_OUTPUT
# Output token
rm key.pem
echo "GitHub App installation token generated successfully"
- name: Prepare variables
run: |
echo "TARGET_REPO=${{ github.event.inputs.target_repo }}" >> $GITHUB_ENV
echo "SOURCE_REPO=${{ github.event.inputs.source_repo }}" >> $GITHUB_ENV
echo "ROLLOUT_BRANCH=${{ github.event.inputs.rollout_branch }}" >> $GITHUB_ENV
echo "TEMPLATE_FILE=${{ github.event.inputs.template_file }}" >> $GITHUB_ENV
echo "DRY_RUN=${{ github.event.inputs.dry_run }}" >> $GITHUB_ENV
# Extract project name from target repo
PROJECT_NAME=$(echo "${TARGET_REPO}" | cut -d'/' -f2)
echo "PROJECT_NAME=${PROJECT_NAME}" >> $GITHUB_ENV
# Extract org name from source repo
ORG_NAME=$(echo "$SOURCE_REPO" | cut -d'/' -f1)
echo "ORG_NAME=${ORG_NAME}" >> $GITHUB_ENV
- name: Check whether label-directory.json exists in target repo
id: check-target-label-dir
run: |
DEST_FILEPATH="target/github-actions/workflow-configs/_data/label-directory.json"
if [ -f "$DEST_FILEPATH" ]; then
echo "TARGET_EXISTS=true" >> $GITHUB_ENV
echo "Label directory file exists in target repo. Do not overwrite."
else
echo "TARGET_EXISTS=false" >> $GITHUB_ENV
echo "Label directory file does not exist in target repo. Checking source..."
fi
echo "DEST_FILEPATH=$DEST_FILEPATH" >> $GITHUB_ENV
- name: Check whether label-directory.{project_name}.json exists in source repo
id: check-source-label-dir
run: |
if [[ "$TARGET_EXISTS" == "true" ]]; then
echo "Skip source check because target exists."
exit 0
fi
SRC_FILENAME="label-directory.${PROJECT_NAME}.json"
SRC_FILEPATH="central/example-configs/$SRC_FILENAME"
if [ -f "$SRC_FILEPATH" ]; then
echo "SOURCE_EXISTS=true" >> $GITHUB_ENV
echo "Label directory file exists in source repo. Proceeding..."
else
echo "SOURCE_EXISTS=false" >> $GITHUB_ENV
echo "Label directory file does not exist in source repo."
echo " ⮡ Run Rollout Project Label Directory first. Exiting..."
exit 1
fi
# persist for downstream steps
echo "SRC_FILENAME=$SRC_FILENAME" >> $GITHUB_ENV
echo "SRC_FILEPATH=$SRC_FILEPATH" >> $GITHUB_ENV
- name: Checkout source repo
uses: actions/checkout@v5
with:
repository: ${{ github.event.inputs.source_repo }}
path: central
- name: Extract rollout config from PR template
id: parse-config
run: |
# Check if source file exists
TEMPLATE_PATH="central/.github/templates/$TEMPLATE_FILE"
if [[ ! -f "$TEMPLATE_PATH" ]]; then
echo "Error: Template file not found at $TEMPLATE_PATH"
exit 1
fi
# Extract YAML/YML block from TEMPLATE_FILE into a temp YAML file
awk '/^```ya?ml[[:space:]]*$/{flag=1;next}/^```[[:space:]]*$/{flag=0}flag' "$TEMPLATE_PATH" > rollout-config.yaml
# Verify extraction worked
if [[ ! -s rollout-config.yaml ]]; then
echo "Error: Failed to extract YAML or file is empty"
exit 1
fi
echo "Saved rollout-config.yaml:"
cat rollout-config.yaml
# Parse workflow file mapping (these are relative to the template location)
WORKFLOW_SRC_RAW=$(yq -r '.workflow_file.src' rollout-config.yaml)
WORKFLOW_DEST=$(yq -r '.workflow_file.dest' rollout-config.yaml)
# Validate extraction
if [[ "$WORKFLOW_SRC_RAW" == "null" ]] || [[ -z "$WORKFLOW_SRC_RAW" ]]; then
echo "Error: Failed to extract workflow_file.src from rollout-config.yaml"
exit 1
fi
# Resolve the relative path from template location to actual file
# Template is at: central/.github/templates/pr_template_add_update.md
# WORKFLOW_SRC_RAW is relative to that location (e.g., ../../example-configs/file.yml)
WORKFLOW_SRC="central/.github/templates/$WORKFLOW_SRC_RAW"
WORKFLOW_SRC=$(python3 -c "import os; print(os.path.normpath('$WORKFLOW_SRC'))")
echo "WORKFLOW_SRC=$WORKFLOW_SRC" >> $GITHUB_ENV
echo "WORKFLOW_DEST=$WORKFLOW_DEST" >> $GITHUB_ENV
echo "Resolved workflow source path: $WORKFLOW_SRC"
# Parse and resolve config_files paths
yq -o=json '.config_files' rollout-config.yaml > config_files_raw.json
echo "[]" > config_files.json
cat config_files_raw.json | jq -c '.[]' | while read -r item; do
SRC_RAW=$(echo "$item" | jq -r '.src')
DEST=$(echo "$item" | jq -r '.dest')
# Resolve relative path from template directory
SRC_FULL="central/.github/templates/$SRC_RAW"
SRC_RESOLVED=$(python3 -c "import os; print(os.path.normpath('$SRC_FULL'))")
# Append to resolved config file
jq --arg src "$SRC_RESOLVED" --arg dest "$DEST" '. += [{src: $src, dest: $dest}]' config_files.json > config_files_temp.json
mv config_files_temp.json config_files.json
done
echo "Parsed and resolved config_files.json:"
cat config_files.json
- name: Append source file if needed
run: |
# Only append if target file does NOT exist but source file DOES exist
if [[ "$TARGET_EXISTS" == "false" && "$SOURCE_EXISTS" == "true" ]]; then
echo "Appending label-directory.json to config_files.json"
jq --arg src "$SRC_FILEPATH" --arg dest "$DEST_FILEPATH" \
'. += [{src: $src, dest: $dest}]' config_files.json > config_files_temp.json
mv config_files_temp.json config_files.json
echo "Appended $SRC_FILEPATH -> $DEST_FILEPATH to config_files.json"
fi
- name: Extract workflow name from workflow source
run: |
# Verify workflow source file exists
if [[ ! -f "$WORKFLOW_SRC" ]]; then
echo "Error: Workflow source file not found at $WORKFLOW_SRC"
echo "Expected path: $WORKFLOW_SRC"
exit 1
fi
WORKFLOW_NAME=$(grep -E '^name:' "$WORKFLOW_SRC" | sed 's/^name:[[:space:]]*//')
if [[ -z "$WORKFLOW_NAME" ]]; then
echo "Error: Could not extract workflow name from $WORKFLOW_SRC"
exit 1
fi
echo "WORKFLOW_NAME=$WORKFLOW_NAME" >> $GITHUB_ENV
echo "Workflow name: $WORKFLOW_NAME"
- name: Rollout to target repo
env:
APP_TOKEN: ${{ steps.generate-app-token.outputs.token }}
run: |
REPO="$TARGET_REPO"
echo "Processing repo: $REPO . . ."
export GH_TOKEN="$APP_TOKEN"
# Get default branch
DEFAULT_BRANCH=$(gh api repos/$REPO --jq '.default_branch'\
-H "Authorization: token $GH_TOKEN")
echo "Default branch: $DEFAULT_BRANCH"
# Only in 'DRY_RUN' mode
if [ "$DRY_RUN" = "true" ]; then
echo "[DRY-RUN] Would clone repo: $REPO (branch: $DEFAULT_BRANCH)"
echo "[DRY-RUN] Would create branch: $ROLLOUT_BRANCH"
echo "[DRY-RUN] Would copy workflow file: $WORKFLOW_SRC -> $WORKFLOW_DEST"
if [ -s config_files.json ]; then
cat config_files.json | jq -c '.[]' | while read -r item; do
SRC=$(echo "$item" | jq -r '.src')
DEST=$(echo "$item" | jq -r '.dest')
echo "[DRY-RUN] Would copy config file: $SRC -> $DEST"
done
else
echo "[DRY-RUN] No config files listed."
fi
echo "[DRY-RUN] Would commit changes: Install ${WORKFLOW_NAME} and project-specific files"
echo "[DRY-RUN] Would push branch: $ROLLOUT_BRANCH"
# read PR template for preview
PR_BODY=$(cat "central/.github/templates/$TEMPLATE_FILE")
echo "[DRY-RUN] PR body preview:"
echo "----------------------------------"
echo "$PR_BODY"
echo "----------------------------------"
echo "[DRY-RUN] Would create PR titled: Install workflow: ${WORKFLOW_NAME}"
exit 0
fi
# Clone the target repository
echo "Cloning repository: $REPO (branch: $DEFAULT_BRANCH)"
if ! git clone --depth 1 --branch "$DEFAULT_BRANCH" \
"https://x-access-token:${APP_TOKEN}@github.com/${REPO}" temp-repo; then
echo "Error: Failed to clone repository $REPO"
exit 1
fi
cd temp-repo
# Configure git to use token for authenticated operations
ORG_NAME=$(echo "$SOURCE_REPO" | cut -d'/' -f1)
git config user.name "${{ github.event.inputs.github_app_name }}[bot]"
git config user.email "${{ github.event.inputs.github_app_name }}@${ORG_NAME}.org"
# Create rollout branch is does not exist
if git ls-remote --heads origin "$ROLLOUT_BRANCH" | grep -q "$ROLLOUT_BRANCH"; then
echo "Checking out existing branch: $ROLLOUT_BRANCH"
git fetch origin "$ROLLOUT_BRANCH"
git checkout "$ROLLOUT_BRANCH"
git pull origin "$ROLLOUT_BRANCH"
else
echo "Creating new branch: $ROLLOUT_BRANCH"
git checkout -b "$ROLLOUT_BRANCH"
fi
# Copy workflow file using workspace-relative path
# $WORKFLOW_SRC is now relative to $GITHUB_WORKSPACE
mkdir -p "$(dirname "$WORKFLOW_DEST")"
cp "$GITHUB_WORKSPACE/$WORKFLOW_SRC" "$WORKFLOW_DEST"
echo "Copied workflow: $WORKFLOW_SRC -> $WORKFLOW_DEST"
# Copy project-specific config files using resolved paths
if [ -s "$GITHUB_WORKSPACE/config_files.json" ]; then
cat "$GITHUB_WORKSPACE/config_files.json" | jq -c '.[]' | while read -r item; do
SRC=$(echo "$item" | jq -r '.src')
DST=$(echo "$item" | jq -r '.dest')
# Verify source file exists
if [[ ! -f "$GITHUB_WORKSPACE/$SRC" ]]; then
echo "Warning: Config file not found: $SRC"
continue
fi
mkdir -p "$(dirname "$DST")"
cp "$GITHUB_WORKSPACE/$SRC" "$DST"
echo "Copied config: $SRC -> $DST"
done
fi
# Commit and push if there are changes
git add .
if git diff --staged --quiet; then
echo "No changes to commit - workflow already up to date"
cd ..
rm -rf temp-repo
exit 0
fi
git commit -m "Install ${WORKFLOW_NAME} and project-specific files"
# Force push rollout branch
git push origin "$ROLLOUT_BRANCH" --force
# Create Pull Request
PR_BODY=$(cat "../central/.github/templates/$TEMPLATE_FILE")
gh pr create \
--repo $REPO \
--head "$ROLLOUT_BRANCH" \
--base "$DEFAULT_BRANCH" \
--title "Install workflow: ${WORKFLOW_NAME}" \
--body "$PR_BODY"
# Cleanup
cd ..
rm -rf temp-repo