Rollout Workflow to Project #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |