diff --git a/.github/workflows/bootstrap-copilot-sync.yml b/.github/workflows/bootstrap-copilot-sync.yml new file mode 100644 index 0000000..ec6bb1a --- /dev/null +++ b/.github/workflows/bootstrap-copilot-sync.yml @@ -0,0 +1,175 @@ +name: Bootstrap Copilot Sync +# One-time bootstrap workflow that creates PRs in all Cratis repositories to add +# thin wrapper workflows that delegate to the reusable workflows in this repository. +# Trigger this workflow manually once to set up all repositories. +# Requires PAT_DOCUMENTATION secret with permissions: +# - repo (to create branches, push files, and open pull requests) + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + bootstrap: + runs-on: ubuntu-latest + + steps: + - name: Get all Cratis repositories + id: get-repos + env: + GH_TOKEN: ${{ secrets.PAT_DOCUMENTATION }} + run: | + # List all non-archived repos (limit 1000 covers foreseeable growth) + repos=$(gh repo list Cratis --limit 1000 --json name,isArchived \ + --jq '[.[] | select(.isArchived == false) | .name]') + echo "repos=$repos" >> $GITHUB_OUTPUT + + - name: Create PRs in all repositories + env: + GH_TOKEN: ${{ secrets.PAT_DOCUMENTATION }} + run: | + repos='${{ steps.get-repos.outputs.repos }}' + + echo "$repos" | jq -r '.[]' | while read -r repo; do + # Skip this repository (Workflows) — it holds the reusable workflows + if [ "$repo" = "Workflows" ]; then + echo "Skipping Workflows (this repository)" + continue + fi + + echo "Processing Cratis/$repo..." + + # Get default branch SHA + default_branch=$(gh api repos/Cratis/$repo --jq '.default_branch' 2>/dev/null) + if [ -z "$default_branch" ]; then + echo " ⚠ Could not get default branch for $repo, skipping" + continue + fi + + sha=$(gh api repos/Cratis/$repo/git/ref/heads/$default_branch \ + --jq '.object.sha' 2>/dev/null) + if [ -z "$sha" ]; then + echo " ⚠ Could not get SHA for $repo, skipping" + continue + fi + + branch="add-copilot-sync-workflows" + + # Create the branch (ignore error if it already exists) + gh api --method POST repos/Cratis/$repo/git/refs \ + -f ref="refs/heads/$branch" \ + -f sha="$sha" 2>/dev/null || true + + # Content for sync wrapper workflow + sync_content=$(cat <<'SYNC_EOF' +name: Sync Copilot Instructions + +on: + workflow_dispatch: + inputs: + source_repository: + description: 'Source repository (owner/repo format)' + required: true + type: string + +jobs: + sync: + uses: Cratis/Workflows/.github/workflows/sync-copilot-instructions.yml@main + with: + source_repository: ${{ inputs.source_repository }} + secrets: inherit +SYNC_EOF +) + + # Content for propagate wrapper workflow + propagate_content=$(cat <<'PROPAGATE_EOF' +name: Propagate Copilot Instructions + +on: + push: + branches: ["main"] + paths: + - ".github/copilot-instructions.md" + - ".github/instructions/**" + - ".github/agents/**" + +jobs: + propagate: + uses: Cratis/Workflows/.github/workflows/propagate-copilot-instructions.yml@main + secrets: inherit +PROPAGATE_EOF +) + + # Ensure .github/workflows directory path + sync_path=".github/workflows/sync-copilot-instructions.yml" + propagate_path=".github/workflows/propagate-copilot-instructions.yml" + + sync_b64=$(echo "$sync_content" | base64 -w 0) + propagate_b64=$(echo "$propagate_content" | base64 -w 0) + + # Push sync workflow file (update if exists, create if not) + existing_sha=$(gh api repos/Cratis/$repo/contents/$sync_path \ + --jq '.sha' 2>/dev/null || true) + + if [ -n "$existing_sha" ]; then + gh api --method PUT repos/Cratis/$repo/contents/$sync_path \ + -f message="Add sync-copilot-instructions workflow" \ + -f content="$sync_b64" \ + -f sha="$existing_sha" \ + -f branch="$branch" 2>/dev/null || \ + echo " ⚠ Could not update sync workflow in $repo" + else + gh api --method PUT repos/Cratis/$repo/contents/$sync_path \ + -f message="Add sync-copilot-instructions workflow" \ + -f content="$sync_b64" \ + -f branch="$branch" 2>/dev/null || \ + echo " ⚠ Could not create sync workflow in $repo" + fi + + # Push propagate workflow file + existing_sha=$(gh api repos/Cratis/$repo/contents/$propagate_path \ + --jq '.sha' 2>/dev/null || true) + + if [ -n "$existing_sha" ]; then + gh api --method PUT repos/Cratis/$repo/contents/$propagate_path \ + -f message="Add propagate-copilot-instructions workflow" \ + -f content="$propagate_b64" \ + -f sha="$existing_sha" \ + -f branch="$branch" 2>/dev/null || \ + echo " ⚠ Could not update propagate workflow in $repo" + else + gh api --method PUT repos/Cratis/$repo/contents/$propagate_path \ + -f message="Add propagate-copilot-instructions workflow" \ + -f content="$propagate_b64" \ + -f branch="$branch" 2>/dev/null || \ + echo " ⚠ Could not create propagate workflow in $repo" + fi + + # Create PR + existing_pr=$(gh pr list --repo Cratis/$repo \ + --head "$branch" --json number --jq '.[0].number' 2>/dev/null || true) + + if [ -z "$existing_pr" ]; then + if gh pr create \ + --repo "Cratis/$repo" \ + --title "Add Copilot sync workflows" \ + --body "Adds thin wrapper workflows that delegate to the reusable workflows in [Cratis/Workflows](https://github.com/Cratis/Workflows). + +### Workflows added + +- \`.github/workflows/sync-copilot-instructions.yml\` — triggered via \`workflow_dispatch\` to pull Copilot instructions from a source repository and open a PR with the changes. +- \`.github/workflows/propagate-copilot-instructions.yml\` — triggered on push to \`main\` when Copilot instruction files change, propagating updates to all Cratis repositories. + +The actual logic lives in [Cratis/Workflows](https://github.com/Cratis/Workflows) so it can be maintained in one place." \ + --head "$branch" \ + --base "$default_branch" 2>/dev/null; then + echo " ✓ Created PR for $repo" + else + echo " ⚠ Could not create PR for $repo" + fi + else + echo " ℹ PR already exists for $repo (#$existing_pr)" + fi + done diff --git a/.github/workflows/propagate-copilot-instructions.yml b/.github/workflows/propagate-copilot-instructions.yml new file mode 100644 index 0000000..9b63569 --- /dev/null +++ b/.github/workflows/propagate-copilot-instructions.yml @@ -0,0 +1,59 @@ +name: Propagate Copilot Instructions +# Reusable workflow that propagates Copilot instruction changes from the calling +# repository to all other repositories in the Cratis organization. +# This workflow requires PAT_DOCUMENTATION secret with permissions: +# - repo (to list repositories and trigger workflows) + +on: + workflow_call: + secrets: + PAT_DOCUMENTATION: + required: true + +permissions: + contents: read + +jobs: + propagate: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'Cratis' }} + + steps: + - name: Get all Cratis repositories + id: get-repos + env: + GH_TOKEN: ${{ secrets.PAT_DOCUMENTATION }} + run: | + # Get all repositories in the Cratis organization (limit 1000 covers foreseeable growth) + repos=$(gh repo list Cratis --limit 1000 --json name --jq '.[].name') + + # Convert to JSON array + echo "repos<> $GITHUB_OUTPUT + echo "$repos" | jq -R -s -c 'split("\n") | map(select(length > 0))' >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Trigger sync workflow for each repository + env: + GH_TOKEN: ${{ secrets.PAT_DOCUMENTATION }} + run: | + repos='${{ steps.get-repos.outputs.repos }}' + source_repo="${{ github.repository }}" + source_repo_name="${{ github.event.repository.name }}" + + echo "Triggering sync for repositories in Cratis organization (source: $source_repo)..." + echo "$repos" | jq -r '.[]' | while read -r repo; do + # Skip the source repository to avoid infinite loop + if [ "$repo" = "$source_repo_name" ]; then + echo "Skipping $repo (source repository)" + continue + fi + + echo "Triggering sync for Cratis/$repo..." + if gh workflow run "sync-copilot-instructions.yml" \ + --repo "Cratis/$repo" \ + --raw-field source_repository="$source_repo" 2>&1; then + echo " ✓ Successfully triggered workflow for $repo" + else + echo " ⚠ Could not trigger workflow for $repo (workflow may not exist)" + fi + done diff --git a/.github/workflows/sync-copilot-instructions.yml b/.github/workflows/sync-copilot-instructions.yml new file mode 100644 index 0000000..f7374eb --- /dev/null +++ b/.github/workflows/sync-copilot-instructions.yml @@ -0,0 +1,102 @@ +name: Sync Copilot Instructions +# Reusable workflow that synchronizes Copilot instructions from a source repository. +# This workflow requires PAT_DOCUMENTATION secret with permissions: +# - contents: write (to checkout and modify files) +# - pull-requests: write (to create pull requests) + +on: + workflow_call: + inputs: + source_repository: + description: 'Source repository (owner/repo format)' + required: true + type: string + secrets: + PAT_DOCUMENTATION: + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + sync-instructions: + runs-on: ubuntu-latest + + steps: + - name: Checkout current repository + uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.PAT_DOCUMENTATION }} + + - name: Validate source repository input + run: | + input="${{ inputs.source_repository }}" + if ! echo "$input" | grep -qE '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$'; then + echo "Error: Invalid repository format. Expected 'owner/repo'" + exit 1 + fi + owner="${input%%/*}" + if [ "$owner" != "Cratis" ]; then + echo "Error: Source repository must belong to the Cratis organization (got '$owner')" + exit 1 + fi + + - name: Clone source repository + env: + GH_TOKEN: ${{ secrets.PAT_DOCUMENTATION }} + run: | + gh repo clone "${{ inputs.source_repository }}" /tmp/source-repo + + - name: Extract and copy Copilot instructions + run: | + mkdir -p .github + + # Copy root Copilot instructions file if it exists + if [ -f "/tmp/source-repo/.github/copilot-instructions.md" ]; then + cp /tmp/source-repo/.github/copilot-instructions.md .github/copilot-instructions.md + echo "Copied copilot-instructions.md" + fi + + # Copy instructions folder if it exists in source + if [ -d "/tmp/source-repo/.github/instructions" ]; then + rm -rf .github/instructions + cp -r /tmp/source-repo/.github/instructions .github/instructions + echo "Copied instructions folder" + fi + + # Copy agents folder if it exists in source + if [ -d "/tmp/source-repo/.github/agents" ]; then + rm -rf .github/agents + cp -r /tmp/source-repo/.github/agents .github/agents + echo "Copied agents folder" + fi + + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.PAT_DOCUMENTATION }} + commit-message: "Copilot Instructions synchronization from ${{ inputs.source_repository }}" + branch: "copilot-sync/${{ github.run_id }}-${{ github.run_attempt }}" + delete-branch: true + title: "Sync Copilot Instructions from ${{ inputs.source_repository }}" + body: | + This PR synchronizes Copilot instructions from [${{ inputs.source_repository }}](https://github.com/${{ inputs.source_repository }}). + + ### Changes include: + - Updated `.github/copilot-instructions.md` (if present) + - Updated `.github/instructions/` folder (if present) + - Updated `.github/agents/` folder (if present) + + **Source repository:** ${{ inputs.source_repository }} + **Triggered by workflow run:** ${{ github.run_id }} + **Run attempt:** ${{ github.run_attempt }} + labels: | + automated + copilot-sync diff --git a/README.md b/README.md index e39fa9a..f2fea3e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,161 @@ # Workflows -Common GitHub workflows + +> [!IMPORTANT] +> This repository is for use by the **[Cratis](https://github.com/Cratis) organization only**. The reusable workflows here are designed specifically for the Cratis GitHub organization and include runtime validation that rejects calls from outside it. + +Common reusable GitHub Actions workflows for Cratis repositories. + +## Getting started with your Cratis repository + +To connect a Cratis repository to the shared Copilot synchronization system, add two thin wrapper workflows to your repository. The easiest way is to trigger the [Bootstrap Copilot Sync](#bootstrap-copilot-sync-one-time-setup) workflow once — it will open a PR in every Cratis repository automatically. + +If you prefer to add the workflows manually, create the following two files: + +**`.github/workflows/sync-copilot-instructions.yml`** + +```yaml +name: Sync Copilot Instructions + +on: + workflow_dispatch: + inputs: + source_repository: + description: 'Source repository (owner/repo format)' + required: true + type: string + +jobs: + sync: + uses: Cratis/Workflows/.github/workflows/sync-copilot-instructions.yml@main + with: + source_repository: ${{ inputs.source_repository }} + secrets: inherit +``` + +**`.github/workflows/propagate-copilot-instructions.yml`** + +```yaml +name: Propagate Copilot Instructions + +on: + push: + branches: ["main"] + paths: + - ".github/copilot-instructions.md" + - ".github/instructions/**" + - ".github/agents/**" + +jobs: + propagate: + uses: Cratis/Workflows/.github/workflows/propagate-copilot-instructions.yml@main + secrets: inherit +``` + +Both workflows require the `PAT_DOCUMENTATION` secret (a GitHub Personal Access Token with `repo` scope) to be set in the repository or inherited from the organization. + +--- + +## How it works + +### Copilot instruction synchronization + +Copilot artifacts are kept in one authoritative repository and automatically propagated to all other Cratis repositories whenever they change. + +The artifacts that are synchronized are: + +| Path | Description | +|---|---| +| `.github/copilot-instructions.md` | Root Copilot instructions file | +| `.github/instructions/` | Folder of scoped instruction files | +| `.github/agents/` | Folder of custom agent definitions | + +### Propagation flow + +When Copilot instruction files are pushed to `main` in any Cratis repository: + +```mermaid +sequenceDiagram + participant Source as Source Repo
(e.g. Chronicle) + participant Propagate as propagate-copilot-instructions
(Cratis/Workflows) + participant Sync as sync-copilot-instructions
(Cratis/Workflows) + participant Target as Target Repo
(e.g. Arc, Fundamentals, …) + + Source->>Propagate: push to main
(copilot paths changed) + Propagate->>Propagate: Validate caller is Cratis org + Propagate->>Propagate: List all Cratis repositories + loop For each target repo (except source) + Propagate->>Target: Trigger sync-copilot-instructions
(source_repository = Source) + Target->>Sync: workflow_call + Sync->>Sync: Validate source is Cratis org + Sync->>Source: Clone source repo + Sync->>Target: Copy copilot artifacts + Sync->>Target: Open PR with changes + end +``` + +### Sync workflow detail + +```mermaid +flowchart TD + A([workflow_dispatch / workflow_call\nsource_repository input]) --> B{Validate format\nand Cratis org} + B -- invalid --> Z([Exit with error]) + B -- valid --> C[Checkout target repo] + C --> D[Clone source repo] + D --> E{copilot-instructions.md\nexists in source?} + E -- yes --> F[Copy to .github/] + E -- no --> G + F --> G{instructions/ folder\nexists in source?} + G -- yes --> H[Replace .github/instructions/] + G -- no --> I + H --> I{agents/ folder\nexists in source?} + I -- yes --> J[Replace .github/agents/] + I -- no --> K + J --> K[Create PR with changes] + K --> L([Done]) +``` + +--- + +## Workflows in this repository + +### `sync-copilot-instructions.yml` + +**Trigger:** `workflow_call` (invoked by each target repository) + +Clones the `source_repository`, extracts the Copilot artifacts, and opens a pull request in the calling repository with the synchronized changes. + +**Inputs:** + +| Input | Required | Description | +|---|---|---| +| `source_repository` | ✅ | Source repository in `owner/repo` format. Must belong to the Cratis organization. | + +**Secrets required:** `PAT_DOCUMENTATION` (`repo` scope) + +--- + +### `propagate-copilot-instructions.yml` + +**Trigger:** `workflow_call` (invoked by the source repository on push to `main`) + +Lists all repositories in the Cratis organization and triggers `sync-copilot-instructions.yml` in each one (except the caller). Silently skips repositories where the workflow file does not exist. + +**Validation:** Exits early if the calling repository does not belong to the `Cratis` organization. + +**Secrets required:** `PAT_DOCUMENTATION` (`repo` scope) + +--- + +### `bootstrap-copilot-sync.yml` + +**Trigger:** `workflow_dispatch` (run once, manually) + +One-time setup workflow. For every non-archived repository in the Cratis organization (except `Workflows` itself), it: + +1. Creates a branch `add-copilot-sync-workflows` +2. Commits the two thin wrapper workflows shown in [Getting started](#getting-started-with-your-cratis-repository) +3. Opens a pull request targeting the repository's default branch + +Re-running the workflow is safe — it skips repositories where the PR branch already exists. + +**Secrets required:** `PAT_DOCUMENTATION` (`repo` scope)