Sync Actions from gh-aw #9
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: Sync Actions from gh-aw | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| ref: | |
| description: 'Ref to sync from gh-aw (tag, branch, SHA, or "latest"). Defaults to latest release.' | |
| required: false | |
| default: 'latest' | |
| type: string | |
| jobs: | |
| sync: | |
| name: Sync Actions | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - name: Log workflow context | |
| run: | | |
| echo "::group::Workflow Context" | |
| echo "Repository: ${{ github.repository }}" | |
| echo "Actor: $ACTOR" | |
| echo "Event: ${{ github.event_name }}" | |
| echo "Ref: $GH_REF" | |
| echo "Run ID: ${{ github.run_id }}" | |
| echo "Run number: ${{ github.run_number }}" | |
| echo "Workflow dispatch input ref: $INPUT_REF" | |
| echo "::endgroup::" | |
| env: | |
| GH_REF: ${{ github.ref }} | |
| ACTOR: ${{ github.actor }} | |
| INPUT_REF: ${{ inputs.ref }} | |
| - name: Check repository is not a fork | |
| run: | | |
| echo "::group::Fork Check" | |
| REPO="${{ github.repository }}" | |
| echo "Checking if '$REPO' is a fork..." | |
| IS_FORK=$(gh api "repos/$REPO" --jq '.fork') | |
| echo "Is fork: $IS_FORK" | |
| if [[ "$IS_FORK" == "true" ]]; then | |
| echo "::error::This workflow is disabled on forks. Repository '$REPO' is a fork." | |
| exit 1 | |
| fi | |
| echo "Repository '$REPO' is not a fork. ✓" | |
| echo "::endgroup::" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Check actor has admin or maintainer role | |
| run: | | |
| echo "::group::Permission Check" | |
| ACTOR="$GITHUB_ACTOR" | |
| REPO="${{ github.repository }}" | |
| echo "Checking permissions for actor '$ACTOR' on '$REPO'..." | |
| ROLE=$(gh api "repos/$REPO/collaborators/$ACTOR/permission" --jq '.role_name' 2>/dev/null || echo "none") | |
| echo "Actor role: $ROLE" | |
| if [[ "$ROLE" != "admin" && "$ROLE" != "maintain" ]]; then | |
| echo "::error::Actor '$ACTOR' does not have the required role (role: '$ROLE'). This workflow requires admin or maintainer access." | |
| exit 1 | |
| fi | |
| echo "Actor '$ACTOR' has required permissions (role: $ROLE). ✓" | |
| echo "::endgroup::" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve ref | |
| id: resolve-ref | |
| run: | | |
| echo "::group::Resolving Ref" | |
| # Determine raw ref from event inputs | |
| RAW_REF="${INPUT_REF}" | |
| # Default to 'latest' if empty | |
| if [[ -z "$RAW_REF" ]]; then | |
| RAW_REF="latest" | |
| fi | |
| echo "Raw ref: $RAW_REF" | |
| # Validate ref format: must be a semver (vMAJOR.MINOR.PATCH), a 40-char SHA, or "latest" | |
| SEMVER_PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$' | |
| if [[ "$RAW_REF" != "latest" && ! "$RAW_REF" =~ $SEMVER_PATTERN && ! "$RAW_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::Invalid ref '$RAW_REF'. Must be a semver version (vMAJOR.MINOR.PATCH), a 40-character SHA, or 'latest'." | |
| exit 1 | |
| fi | |
| # Verify the ref exists in github/gh-aw and is reachable from the main branch | |
| # (skip for 'latest', which is resolved to the newest release below) | |
| if [[ "$RAW_REF" != "latest" ]]; then | |
| echo "Verifying '$RAW_REF' exists in github/gh-aw..." | |
| API_ERROR=$(mktemp) | |
| COMMIT_SHA=$(gh api "repos/github/gh-aw/commits/$RAW_REF" --jq '.sha' 2>"$API_ERROR" || echo "") | |
| if [[ -z "$COMMIT_SHA" ]]; then | |
| if [[ -s "$API_ERROR" ]]; then | |
| echo "::error::Failed to verify ref '$RAW_REF' in github/gh-aw: $(cat "$API_ERROR")" | |
| else | |
| echo "::error::Ref '$RAW_REF' was not found in the github/gh-aw repository." | |
| fi | |
| rm -f "$API_ERROR" | |
| exit 1 | |
| fi | |
| rm -f "$API_ERROR" | |
| echo "Ref '$RAW_REF' resolved to commit $COMMIT_SHA." | |
| # Confirm the commit is reachable from the main branch. | |
| # compare/main...<sha>: status "behind" or "identical" means the commit | |
| # is an ancestor of (or equal to) main HEAD. | |
| echo "Checking that $COMMIT_SHA is in the main branch history of github/gh-aw..." | |
| API_ERROR=$(mktemp) | |
| COMPARE_STATUS=$(gh api "repos/github/gh-aw/compare/main...$COMMIT_SHA" --jq '.status' 2>"$API_ERROR" || echo "") | |
| if [[ -z "$COMPARE_STATUS" ]]; then | |
| echo "::error::Failed to compare ref '$RAW_REF' against main in github/gh-aw: $(cat "$API_ERROR")" | |
| rm -f "$API_ERROR" | |
| exit 1 | |
| fi | |
| rm -f "$API_ERROR" | |
| if [[ "$COMPARE_STATUS" != "behind" && "$COMPARE_STATUS" != "identical" ]]; then | |
| echo "::error::Ref '$RAW_REF' (commit $COMMIT_SHA) is not in the main branch history of github/gh-aw (compare status: '$COMPARE_STATUS')." | |
| exit 1 | |
| fi | |
| echo "Ref '$RAW_REF' is in the main branch history of github/gh-aw. ✓" | |
| fi | |
| if [[ "$RAW_REF" == "latest" ]]; then | |
| echo "Resolving 'latest' to the most recent gh-aw release..." | |
| LATEST_TAG=$(gh api repos/github/gh-aw/releases/latest --jq '.tag_name' 2>/dev/null || echo "") | |
| if [[ -n "$LATEST_TAG" ]]; then | |
| RESOLVED_REF="$LATEST_TAG" | |
| echo "Latest release tag resolved to: $RESOLVED_REF" | |
| else | |
| echo "::warning::No releases found in gh-aw. Falling back to main branch HEAD." | |
| RESOLVED_REF=$(gh api repos/github/gh-aw/commits/main --jq '.sha') | |
| echo "Using main HEAD SHA: $RESOLVED_REF" | |
| fi | |
| else | |
| RESOLVED_REF="$RAW_REF" | |
| echo "Using provided ref: $RESOLVED_REF" | |
| fi | |
| # Determine whether to create a tag after syncing. | |
| # Create a tag when the ref is not a full 40-character SHA and not "latest". | |
| if [[ "$RAW_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| IS_LONG_SHA="true" | |
| else | |
| IS_LONG_SHA="false" | |
| fi | |
| if [[ "$RAW_REF" =~ $SEMVER_PATTERN ]]; then | |
| IS_SEMVER="true" | |
| TAG_REF="$RAW_REF" | |
| elif [[ "$RAW_REF" == "latest" && "$RESOLVED_REF" =~ $SEMVER_PATTERN ]]; then | |
| IS_SEMVER="true" | |
| TAG_REF="$RESOLVED_REF" | |
| else | |
| IS_SEMVER="false" | |
| TAG_REF="$RAW_REF" | |
| fi | |
| if [[ "$IS_SEMVER" == "true" ]]; then | |
| SHOULD_CREATE_TAG="true" | |
| else | |
| SHOULD_CREATE_TAG="false" | |
| fi | |
| echo "Resolved ref: $RESOLVED_REF" | |
| echo "Is long SHA: $IS_LONG_SHA" | |
| echo "Is semver: $IS_SEMVER" | |
| echo "Should create tag: $SHOULD_CREATE_TAG" | |
| echo "Tag ref: $TAG_REF" | |
| echo "resolved_ref=$RESOLVED_REF" >> "$GITHUB_OUTPUT" | |
| echo "raw_ref=$RAW_REF" >> "$GITHUB_OUTPUT" | |
| echo "should_create_tag=$SHOULD_CREATE_TAG" >> "$GITHUB_OUTPUT" | |
| echo "is_semver=$IS_SEMVER" >> "$GITHUB_OUTPUT" | |
| echo "tag_ref=$TAG_REF" >> "$GITHUB_OUTPUT" | |
| echo "::endgroup::" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| INPUT_REF: ${{ inputs.ref }} | |
| - name: Checkout gh-aw-actions (this repository) | |
| uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v4.2.2 | |
| with: | |
| ref: main | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| path: gh-aw-actions | |
| - name: Checkout gh-aw at resolved ref (actions/ only) | |
| uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v4.2.2 | |
| with: | |
| repository: github/gh-aw | |
| ref: ${{ steps.resolve-ref.outputs.resolved_ref }} | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| path: gh-aw | |
| sparse-checkout: actions | |
| sparse-checkout-cone-mode: true | |
| - name: Log source actions directory | |
| run: | | |
| echo "::group::Source — gh-aw/actions/ at ref '${{ steps.resolve-ref.outputs.resolved_ref }}'" | |
| find gh-aw/actions -type f | sort | |
| echo "::endgroup::" | |
| - name: Log destination before sync | |
| run: | | |
| echo "::group::Destination — gh-aw-actions/ (action folders) before sync" | |
| for dir in gh-aw/actions/*/; do | |
| name=$(basename "$dir") | |
| echo "--- $name/ ---" | |
| find "gh-aw-actions/$name" -type f 2>/dev/null | sort || echo "(does not exist yet)" | |
| done | |
| echo "::endgroup::" | |
| - name: Sync action folders (remote wins, local README preserved) | |
| run: | | |
| echo "::group::Syncing action folders from gh-aw/actions/ to repo root" | |
| # --archive preserves permissions/timestamps; --delete removes files absent in source (remote wins) | |
| # --exclude='README*' preserves any local README files in each destination folder | |
| for dir in gh-aw/actions/*/; do | |
| name=$(basename "$dir") | |
| echo "Syncing: gh-aw/actions/$name/ -> gh-aw-actions/$name/" | |
| rsync --archive --verbose --delete --exclude='README*' "gh-aw/actions/$name/" "gh-aw-actions/$name/" | |
| done | |
| echo "::endgroup::" | |
| - name: Log destination after sync | |
| run: | | |
| echo "::group::Destination — gh-aw-actions/ (action folders) after sync" | |
| for dir in gh-aw/actions/*/; do | |
| name=$(basename "$dir") | |
| echo "--- $name/ ---" | |
| find "gh-aw-actions/$name" -type f | sort | |
| done | |
| echo "::endgroup::" | |
| - name: Create branch, commit, and open pull request | |
| id: create-pr | |
| run: | | |
| echo "::group::Git Commit" | |
| cd gh-aw-actions | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| for dir in ../gh-aw/actions/*/; do | |
| name=$(basename "$dir") | |
| git add -A "$name/" | |
| done | |
| if git diff --staged --quiet; then | |
| echo "No changes detected — nothing to commit." | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| echo "pr_number=" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Changes staged for commit:" | |
| git diff --staged --stat | |
| echo "" | |
| BRANCH="sync/gh-aw-${{ steps.resolve-ref.outputs.resolved_ref }}" | |
| git checkout -b "$BRANCH" | |
| COMMIT_MSG="chore: sync actions from gh-aw@${{ steps.resolve-ref.outputs.resolved_ref }}" | |
| git commit -m "$COMMIT_MSG" | |
| git push origin "$BRANCH" | |
| PR_URL=$(gh pr create \ | |
| --title "$COMMIT_MSG" \ | |
| --body "Automated sync of actions from [gh-aw](https://github.com/github/gh-aw) at \`${{ steps.resolve-ref.outputs.resolved_ref }}\`." \ | |
| --base main \ | |
| --head "$BRANCH") | |
| PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') | |
| echo "" | |
| echo "Pull request #$PR_NUMBER created: $PR_URL ✓" | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" | |
| fi | |
| echo "::endgroup::" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Poll pull request until merged | |
| if: steps.create-pr.outputs.changed == 'true' | |
| run: | | |
| echo "::group::Polling PR #${{ steps.create-pr.outputs.pr_number }}" | |
| PR_NUMBER="${{ steps.create-pr.outputs.pr_number }}" | |
| TIMEOUT=1200 | |
| INTERVAL=30 | |
| ELAPSED=0 | |
| while [[ $ELAPSED -lt $TIMEOUT ]]; do | |
| if ! STATE=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json state --jq '.state' 2>&1); then | |
| echo "::warning::Failed to query PR state (elapsed: ${ELAPSED}s): $STATE" | |
| else | |
| echo "PR #$PR_NUMBER state: $STATE (elapsed: ${ELAPSED}s / ${TIMEOUT}s)" | |
| if [[ "$STATE" == "MERGED" ]]; then | |
| echo "Pull request #$PR_NUMBER has been merged. ✓" | |
| echo "::endgroup::" | |
| exit 0 | |
| fi | |
| if [[ "$STATE" == "CLOSED" ]]; then | |
| echo "::error::Pull request #$PR_NUMBER was closed without merging." | |
| echo "::endgroup::" | |
| exit 1 | |
| fi | |
| fi | |
| sleep $INTERVAL | |
| ELAPSED=$((ELAPSED + INTERVAL)) | |
| done | |
| echo "::error::Timed out after ${TIMEOUT}s waiting for pull request #$PR_NUMBER to be merged." | |
| echo "::endgroup::" | |
| exit 1 | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create tag | |
| if: steps.resolve-ref.outputs.should_create_tag == 'true' | |
| run: | | |
| echo "::group::Creating Tag" | |
| cd gh-aw-actions | |
| # When a PR was created and merged, we need to pull the merge commit from main | |
| # because the working directory is still on the sync branch. | |
| if [[ "${{ steps.create-pr.outputs.changed }}" == "true" ]]; then | |
| echo "PR was merged — fetching main to tag the merge commit..." | |
| git fetch origin main | |
| git checkout -B main origin/main | |
| fi | |
| TAG="${{ steps.resolve-ref.outputs.tag_ref }}" | |
| echo "Creating tag: $TAG" | |
| # If the tag already exists, delete and re-create it so it points to the | |
| # new commit produced by this sync run. Re-running a sync for the same | |
| # named ref (e.g. a branch name or a short alias) is an explicit request | |
| # to advance the tag, so force-updating is the expected behaviour. | |
| if git tag -d "$TAG" 2>/dev/null; then | |
| echo "Deleted existing local tag '$TAG' (will re-create at new HEAD)." | |
| fi | |
| if git push origin ":refs/tags/$TAG" 2>/dev/null; then | |
| echo "Deleted existing remote tag '$TAG' (will re-create at new HEAD)." | |
| fi | |
| git tag -a "$TAG" -m "Sync from gh-aw@$TAG" | |
| git push origin "$TAG" | |
| echo "Tag '$TAG' created and pushed. ✓" | |
| # If the ref is a semver, also update the vMAJOR and vMAJOR.MINOR floating tags | |
| if [[ "${{ steps.resolve-ref.outputs.is_semver }}" == "true" ]]; then | |
| # These sed patterns are safe: the format was already validated as vMAJOR.MINOR.PATCH above | |
| MAJOR_TAG="v$(echo "$TAG" | sed 's/^v\([0-9]*\)\..*/\1/')" | |
| MINOR_TAG="v$(echo "$TAG" | sed 's/^v\([0-9]*\.[0-9]*\)\..*/\1/')" | |
| for FLOATING_TAG in "$MAJOR_TAG" "$MINOR_TAG"; do | |
| echo "Updating floating tag: $FLOATING_TAG" | |
| if git tag -d "$FLOATING_TAG" 2>/dev/null; then | |
| echo "Deleted existing local tag '$FLOATING_TAG'." | |
| fi | |
| if git push origin ":refs/tags/$FLOATING_TAG" 2>/dev/null; then | |
| echo "Deleted existing remote tag '$FLOATING_TAG'." | |
| fi | |
| git tag -a "$FLOATING_TAG" -m "Sync from gh-aw@$TAG" | |
| git push origin "$FLOATING_TAG" | |
| echo "Floating tag '$FLOATING_TAG' updated. ✓" | |
| done | |
| fi | |
| echo "::endgroup::" |