diff --git a/.cratis/PROJECT.md b/.cratis/PROJECT.md new file mode 100644 index 0000000..82a4d8e --- /dev/null +++ b/.cratis/PROJECT.md @@ -0,0 +1,59 @@ +# Workflows — project context + +Organization-owned reusable GitHub Actions workflows for the Cratis +organization. Not an application: no product code, no `.ai/` corpus — the +reusable workflows and their scripts are the product. Runtime validation +rejects calls from outside the Cratis organization. + +## What lives here + +| Path | Holds | +| --- | --- | +| `.github/workflows/` | the reusable (`workflow_call`) and organization-wide workflows | +| `.github/scripts/` | the scripts those workflows run, plus `update-ai-profile-subscription.mjs` (the subscription update controller) | +| `.github/scripts/tests/` | script tests wired into `verify-*` workflows | + +Key workflows: `publish.template.yml` (publish template), `cleanup-pr-artifacts`, +`auto-approve-publish-deployments`, `bootstrap-common-workflows` (installs the +common wrappers organization-wide), `propagate-pr-templates`, `verify-*` gates, +and `update-ai-profile-subscription.yml` (reviewed Cratis AI profile updates — +see README's *Reviewed Cratis AI profile updates*). + +The legacy Copilot synchronization system (all-to-all propagation, +per-repository sync wrappers, corpus bootstrap, propagation control) is +**retired and removed**. Cratis repositories carry the `.cratis/` AI contract +instead (see README's *AI setup in Cratis repositories*). + +## Conventions + +- Shell scripts under `.github/scripts/` are bash with `set -euo pipefail`; + test what can be tested under `.github/scripts/tests/`. +- Organization-wide write workflows (bootstrap, PR-template propagation) push + only through `PAT_WORKFLOWS` owned by a dedicated service account configured + as a bypass actor; changes to watched files need explicit review before + merge. +- Never commit secret values; workflows reference organization secrets + (`PAT_WORKFLOWS`, `PAT_DOCUMENTATION`) by name only. + +## Local AI work artifacts — `.ai-work/` only + +AI-assisted sessions produce working artifacts: plans, handover documents, +session notes, continuation prompts, status boards, scratch analyses. These are +**work records, not documentation**: + +- Create every such artifact inside **`.ai-work/`** at the repository root — + never at the root itself, never under documentation folders, never anywhere + else. +- `.ai-work/` is gitignored and must stay untracked; never `git add -f` + anything inside it. +- A genuine follow-up that must survive the session becomes a GitHub issue, + not a planning file. Knowledge that must outlive the session belongs in this + repository's documentation through normal review. + +## AI-assisted development + +`.cratis/ai.json` records this repository's profile subscription +(`cratis/documentation` + `cratis/engineering/core`). Shared behavior arrives +via the Cratis AI marketplace plugins — see the +[harness guide](https://www.cratis.io/ai/harnesses/). General improvements are +proposed in `Cratis/AI`; repository-specific facts stay in this file. diff --git a/.cratis/ai.json b/.cratis/ai.json new file mode 100644 index 0000000..fd9b7ac --- /dev/null +++ b/.cratis/ai.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": "1.0.0", + "version": "1.0.0", + "profiles": [ + "cratis/documentation", + "cratis/engineering/core" + ], + "harnesses": [ + "claude", + "codex", + "copilot", + "cursor", + "pi" + ], + "updatePolicy": "reviewed-pull-request", + "projectContext": ".cratis/PROJECT.md" +} diff --git a/.github/scripts/ai-corpus-propagation-control.sh b/.github/scripts/ai-corpus-propagation-control.sh deleted file mode 100755 index 8cf9d57..0000000 --- a/.github/scripts/ai-corpus-propagation-control.sh +++ /dev/null @@ -1,914 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) Cratis. All rights reserved. -# Licensed under the MIT license. See LICENSE file in the project root for full license information. -# -# Freezes, verifies, and deliberately restores the Cratis AI corpus propagation workflows. -# Workflow state is changed through the GitHub Actions API; repository files and refs are untouched. - -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck disable=SC1091 -source "$script_dir/github-api-retry.sh" - -organization="${CRATIS_ORGANIZATION:-Cratis}" -source_repository="${AI_CORPUS_SOURCE_REPOSITORY:-AI}" -workflows_repository="${AI_CORPUS_WORKFLOWS_REPOSITORY:-Workflows}" -operation="${1:-status}" -apply=false -canary_repository="" -snapshot_file="" -confirmation="" -confirm_legacy=false -failures=0 -changed=0 -skipped=0 -cancelled=0 -inventory_file="" -temporary_directory="" - -propagate_path=".github/workflows/propagate-copilot-instructions.yml" -sync_path=".github/workflows/sync-copilot-instructions.yml" -bootstrap_path=".github/workflows/bootstrap-copilot-sync.yml" - -usage() { - cat <<'EOF' -Usage: - ai-corpus-propagation-control.sh status [options] - ai-corpus-propagation-control.sh verify-frozen [options] - ai-corpus-propagation-control.sh freeze --snapshot [--apply] [options] - ai-corpus-propagation-control.sh restore --snapshot [--apply] [options] - ai-corpus-propagation-control.sh canary --repo [--apply] [options] - ai-corpus-propagation-control.sh enable-single-source [--apply] [options] - ai-corpus-propagation-control.sh enable-legacy-all-to-all \ - --confirm-legacy-all-to-all [--apply] [options] - -Operations: - status - Reports controlled workflow states and queued or running runs. - - verify-frozen - Fails unless every controlled workflow is inactive and no controlled run is - queued or running. - - freeze - Takes a complete, versioned state snapshot, disables only workflows that are - currently active, cancels queued or running controlled runs, and waits for - quiescence. Applying a freeze requires --snapshot. - - restore - Validates a freeze snapshot against the complete current topology and enables - only workflows that the snapshot recorded as active. Workflows that were - already inactive before the freeze are never enabled. - - canary - Quiesces the complete propagation system, enables only the central reusable - sync workflow and one target repository's manual sync wrapper, then dispatches - a PR-based sync from the corpus source. - - enable-single-source - Quiesces the complete system, enables the central reusable workflows and - target sync wrappers, and enables automatic propagation only in the - authoritative source repository. - - enable-legacy-all-to-all - Quiesces the complete system, then enables every propagation and sync wrapper - plus bootstrap. This dangerous legacy topology requires explicit confirmation. - -Options: - --apply Perform GitHub mutations. Otherwise, dry-run. - --confirm-organization Required with --apply; must exactly match the org. - --snapshot Snapshot output (freeze) or input (restore). - --repo Canary target repository name. - --organization GitHub organization (default: Cratis). - --source-repository Authoritative corpus repository (default: AI). - --workflows-repository Reusable workflows repository (default: Workflows). - --confirm-legacy-all-to-all Required for the legacy topology. - -h, --help Show this help. - -Prerequisites: Bash, GitHub CLI (gh), jq, and a token with Actions read access. -Mutations additionally require Actions write access to every controlled repository. -EOF -} - -shift || true -while [ "$#" -gt 0 ]; do - case "$1" in - --apply) - apply=true - ;; - --confirm-organization) - [ "$#" -ge 2 ] || { echo "--confirm-organization requires a value" >&2; exit 2; } - confirmation="$2" - shift - ;; - --snapshot) - [ "$#" -ge 2 ] || { echo "--snapshot requires a value" >&2; exit 2; } - snapshot_file="$2" - shift - ;; - --repo) - [ "$#" -ge 2 ] || { echo "--repo requires a value" >&2; exit 2; } - canary_repository="$2" - shift - ;; - --organization) - [ "$#" -ge 2 ] || { echo "--organization requires a value" >&2; exit 2; } - organization="$2" - shift - ;; - --source-repository) - [ "$#" -ge 2 ] || { echo "--source-repository requires a value" >&2; exit 2; } - source_repository="$2" - shift - ;; - --workflows-repository) - [ "$#" -ge 2 ] || { echo "--workflows-repository requires a value" >&2; exit 2; } - workflows_repository="$2" - shift - ;; - --confirm-legacy-all-to-all) - confirm_legacy=true - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - usage >&2 - exit 2 - ;; - esac - shift -done - -require_command() { - command -v "$1" >/dev/null 2>&1 || { - echo "Required command not found: $1" >&2 - exit 2 - } -} - -validate_repository_name() { - local value="$1" - local label="$2" - if [[ ! "$value" =~ ^[A-Za-z0-9_.-]+$ ]]; then - echo "$label must be a repository name, not owner/name: $value" >&2 - exit 2 - fi -} - -cleanup() { - [ -z "$temporary_directory" ] || rm -rf "$temporary_directory" -} -trap cleanup EXIT - -require_command gh -require_command jq -validate_repository_name "$organization" "--organization" -validate_repository_name "$source_repository" "--source-repository" -validate_repository_name "$workflows_repository" "--workflows-repository" -[ -z "$canary_repository" ] || validate_repository_name "$canary_repository" "--repo" - -case "$operation" in - status|verify-frozen) - if [ "$apply" = true ]; then - echo "$operation is read-only and does not accept --apply" >&2 - exit 2 - fi - ;; - freeze) - if [ "$apply" = true ] && [ -z "$snapshot_file" ]; then - echo "freeze --apply requires --snapshot " >&2 - exit 2 - fi - ;; - restore) - [ -n "$snapshot_file" ] || { echo "restore requires --snapshot " >&2; exit 2; } - ;; - canary) - [ -n "$canary_repository" ] || { echo "canary requires --repo " >&2; exit 2; } - ;; - enable-single-source) - ;; - enable-legacy-all-to-all) - [ "$confirm_legacy" = true ] || { - echo "enable-legacy-all-to-all requires --confirm-legacy-all-to-all" >&2 - exit 2 - } - ;; - help|-h|--help) - usage - exit 0 - ;; - *) - echo "Unknown operation: $operation" >&2 - usage >&2 - exit 2 - ;; -esac - -if [ "$apply" = true ] && [ "$confirmation" != "$organization" ]; then - echo "--apply requires --confirm-organization $organization" >&2 - exit 2 -fi - -if [ "$apply" = true ]; then - echo "Mode: APPLY (organization confirmation matched)" -else - echo "Mode: DRY RUN (pass --apply --confirm-organization $organization to mutate GitHub state)" -fi - -echo "Organization: $organization" -echo "Corpus source: $organization/$source_repository" -echo "Reusable workflows: $organization/$workflows_repository" - -temporary_directory=$(mktemp -d) -inventory_file="$temporary_directory/inventory.json" - -list_repository_pages() { - gh_api_with_retry --paginate --slurp "orgs/$organization/repos?per_page=100&type=all" -} - -list_workflow_pages() { - local repository="$1" - gh_api_with_retry --paginate --slurp \ - "repos/$organization/$repository/actions/workflows?per_page=100" -} - -list_run_pages() { - local repository="$1" - local workflow_id="$2" - gh_api_with_retry --paginate --slurp \ - "repos/$organization/$repository/actions/workflows/$workflow_id/runs?per_page=100" -} - -build_inventory() { - local repository_pages - local repositories_json - local workflows_ndjson="$temporary_directory/workflows.ndjson" - local repository - local workflow_pages - local matching_records - local record - local workflow_id - local runs_pages - local active_runs - local duplicate_paths - - : > "$workflows_ndjson" - echo - echo "Preflight: reading the complete controlled-workflow topology..." - - if ! repository_pages=$(list_repository_pages); then - echo "Failed to list repositories in $organization" >&2 - return 1 - fi - if ! repositories_json=$(jq -ce \ - '[.[][] | select(.archived == false) | .name] | unique | sort' \ - <<< "$repository_pages"); then - echo "GitHub returned malformed repository data for $organization" >&2 - return 1 - fi - if [ "$(jq 'length' <<< "$repositories_json")" -eq 0 ]; then - echo "GitHub returned no active repositories for $organization; refusing to continue" >&2 - return 1 - fi - - while IFS= read -r repository; do - if ! workflow_pages=$(list_workflow_pages "$repository"); then - echo " ✗ $organization/$repository :: failed to list workflows" >&2 - return 1 - fi - if ! matching_records=$(jq -ce \ - --arg repository "$repository" \ - --arg workflows_repository "$workflows_repository" \ - --arg propagate "$propagate_path" \ - --arg sync "$sync_path" \ - --arg bootstrap "$bootstrap_path" ' - [ .[]?.workflows[]? - | select( - .path == $propagate or - .path == $sync or - ($repository == $workflows_repository and .path == $bootstrap)) - | { - repository: $repository, - id: .id, - name: .name, - path: .path, - state: .state - } - ]' <<< "$workflow_pages"); then - echo " ✗ $organization/$repository :: malformed workflow response" >&2 - return 1 - fi - - duplicate_paths=$(jq -r 'group_by(.path)[] | select(length > 1) | .[0].path' <<< "$matching_records") - if [ -n "$duplicate_paths" ]; then - echo " ✗ $organization/$repository :: duplicate controlled workflow path(s):" >&2 - printf '%s\n' "$duplicate_paths" | sed 's/^/ /' >&2 - return 1 - fi - - while IFS= read -r record; do - [ -n "$record" ] || continue - workflow_id=$(jq -r '.id' <<< "$record") - if ! runs_pages=$(list_run_pages "$repository" "$workflow_id"); then - echo " ✗ $organization/$repository :: failed to list all runs for workflow $workflow_id" >&2 - return 1 - fi - if ! active_runs=$(jq -ce ' - [ .[]?.workflow_runs[]? - | select(.status != "completed") - | {id: .id, status: .status} - ] - | unique_by(.id) - | sort_by(.id)' <<< "$runs_pages"); then - echo " ✗ $organization/$repository :: malformed run response for workflow $workflow_id" >&2 - return 1 - fi - jq -c --argjson active_runs "$active_runs" '. + {active_runs: $active_runs}' \ - <<< "$record" >> "$workflows_ndjson" - done < <(jq -c '.[]' <<< "$matching_records") - done < <(jq -r '.[]' <<< "$repositories_json") - - jq -n \ - --arg organization "$organization" \ - --arg source_repository "$source_repository" \ - --arg workflows_repository "$workflows_repository" \ - --argjson repositories "$repositories_json" \ - --slurpfile workflows "$workflows_ndjson" ' - { - organization: $organization, - source_repository: $source_repository, - workflows_repository: $workflows_repository, - repositories: $repositories, - workflows: $workflows - }' > "$inventory_file" - - echo " ✓ inspected $(jq '.repositories | length' "$inventory_file") repositories and $(jq '.workflows | length' "$inventory_file") controlled workflows" -} - -workflow_record() { - local repository="$1" - local workflow_path="$2" - jq -c --arg repository "$repository" --arg path "$workflow_path" ' - .workflows[] | select(.repository == $repository and .path == $path)' "$inventory_file" -} - -require_workflow() { - local repository="$1" - local workflow_path="$2" - local count - - count=$(jq --arg repository "$repository" --arg path "$workflow_path" ' - [.workflows[] | select(.repository == $repository and .path == $path)] | length' "$inventory_file") - if [ "$count" -ne 1 ]; then - echo "Required workflow is missing: $organization/$repository/$workflow_path" >&2 - return 1 - fi -} - -require_repository() { - local repository="$1" - if ! jq -e --arg repository "$repository" '.repositories | index($repository) != null' \ - "$inventory_file" >/dev/null; then - echo "Required repository was not found: $organization/$repository" >&2 - return 1 - fi -} - -require_central_workflows() { - require_repository "$workflows_repository" && - require_workflow "$workflows_repository" "$bootstrap_path" && - require_workflow "$workflows_repository" "$propagate_path" && - require_workflow "$workflows_repository" "$sync_path" -} - -set_record_state() { - local record="$1" - local desired_state="$2" - local current_state_override="${3:-}" - local repository - local workflow_id - local workflow_name - local current_state - local action - - repository=$(jq -r '.repository' <<< "$record") - workflow_id=$(jq -r '.id' <<< "$record") - workflow_name=$(jq -r '.name' <<< "$record") - current_state=$(jq -r '.state' <<< "$record") - [ -z "$current_state_override" ] || current_state="$current_state_override" - - if [ "$desired_state" = "disabled_manually" ] && [ "$current_state" != "active" ]; then - echo " = $organization/$repository :: $workflow_name remains $current_state" - skipped=$((skipped + 1)) - return 0 - fi - if [ "$current_state" = "$desired_state" ]; then - echo " = $organization/$repository :: $workflow_name ($current_state)" - skipped=$((skipped + 1)) - return 0 - fi - - if [ "$desired_state" = "active" ]; then - action="enable" - else - action="disable" - fi - - if [ "$apply" = false ]; then - echo " ~ $organization/$repository :: $workflow_name ($current_state -> $desired_state)" - changed=$((changed + 1)) - return 0 - fi - - if gh_api_with_retry -X PUT \ - "repos/$organization/$repository/actions/workflows/$workflow_id/$action" >/dev/null; then - echo " ✓ $organization/$repository :: $workflow_name -> $desired_state" - changed=$((changed + 1)) - else - echo " ✗ $organization/$repository :: failed to set $workflow_name to $desired_state" >&2 - failures=$((failures + 1)) - fi -} - -cancel_run() { - local repository="$1" - local workflow_name="$2" - local run_id="$3" - - if [ "$apply" = false ]; then - echo " ~ $organization/$repository :: cancel run $run_id ($workflow_name)" - cancelled=$((cancelled + 1)) - elif gh_api_with_retry -X POST \ - "repos/$organization/$repository/actions/runs/$run_id/cancel" >/dev/null; then - echo " ✓ $organization/$repository :: canceled run $run_id ($workflow_name)" - cancelled=$((cancelled + 1)) - else - echo " ✗ $organization/$repository :: failed to cancel run $run_id ($workflow_name)" >&2 - failures=$((failures + 1)) - fi -} - -cancel_controlled_runs() { - local record - local repository - local workflow_id - local workflow_name - local runs_pages - local run_id - - while IFS= read -r record; do - repository=$(jq -r '.repository' <<< "$record") - workflow_id=$(jq -r '.id' <<< "$record") - workflow_name=$(jq -r '.name' <<< "$record") - - if [ "$apply" = false ]; then - while IFS= read -r run_id; do - [ -n "$run_id" ] || continue - cancel_run "$repository" "$workflow_name" "$run_id" - done < <(jq -r '.active_runs[].id' <<< "$record") - continue - fi - - # Refresh after all workflow disables. This catches runs that started between - # the preflight inventory and the mutation phase. - if ! runs_pages=$(list_run_pages "$repository" "$workflow_id"); then - echo " ✗ $organization/$repository :: failed to refresh runs for $workflow_name" >&2 - failures=$((failures + 1)) - continue - fi - while IFS= read -r run_id; do - [ -n "$run_id" ] || continue - cancel_run "$repository" "$workflow_name" "$run_id" - done < <(jq -r '.[]?.workflow_runs[]? | select(.status != "completed") | .id' <<< "$runs_pages" | sort -u) - done < <(jq -c '.workflows[]' "$inventory_file") -} - -wait_for_quiescence() { - local timeout_seconds="${AI_CORPUS_QUIESCENCE_TIMEOUT_SECONDS:-300}" - local interval_seconds="${AI_CORPUS_QUIESCENCE_POLL_SECONDS:-5}" - local deadline - local record - local repository - local workflow_id - local workflow_name - local runs_pages - local active_count - local total_active - - [[ "$timeout_seconds" =~ ^[0-9]+$ ]] || timeout_seconds=300 - [[ "$interval_seconds" =~ ^[0-9]+$ ]] || interval_seconds=5 - [ "$interval_seconds" -gt 0 ] || interval_seconds=1 - deadline=$((SECONDS + timeout_seconds)) - - echo "Verifying quiescence (timeout: ${timeout_seconds}s)..." - while true; do - total_active=0 - while IFS= read -r record; do - repository=$(jq -r '.repository' <<< "$record") - workflow_id=$(jq -r '.id' <<< "$record") - workflow_name=$(jq -r '.name' <<< "$record") - if ! runs_pages=$(list_run_pages "$repository" "$workflow_id"); then - echo " ✗ failed to verify runs for $organization/$repository :: $workflow_name" >&2 - failures=$((failures + 1)) - return 1 - fi - if ! active_count=$(jq -e '[.[]?.workflow_runs[]? | select(.status != "completed")] | length' \ - <<< "$runs_pages"); then - echo " ✗ malformed run response while verifying $organization/$repository :: $workflow_name" >&2 - failures=$((failures + 1)) - return 1 - fi - total_active=$((total_active + active_count)) - done < <(jq -c '.workflows[]' "$inventory_file") - - if [ "$total_active" -eq 0 ]; then - echo " ✓ no queued or running controlled workflows remain" - return 0 - fi - if [ "$SECONDS" -ge "$deadline" ]; then - echo " ✗ $total_active controlled run(s) remain after ${timeout_seconds}s" >&2 - failures=$((failures + 1)) - return 1 - fi - echo " … waiting for $total_active controlled run(s) to finish cancellation" - sleep "$interval_seconds" - done -} - -write_snapshot() { - local parent_directory - local temporary_snapshot - - [ -n "$snapshot_file" ] || return 0 - if [ -e "$snapshot_file" ]; then - echo "Snapshot path already exists; refusing to overwrite it: $snapshot_file" >&2 - return 1 - fi - parent_directory=$(dirname "$snapshot_file") - if [ ! -d "$parent_directory" ]; then - echo "Snapshot parent directory does not exist: $parent_directory" >&2 - return 1 - fi - - temporary_snapshot=$(mktemp "$parent_directory/.ai-corpus-snapshot.XXXXXX") - umask 077 - jq --arg created_at "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" ' - { - schema_version: 1, - created_at: $created_at, - organization, - source_repository, - workflows_repository, - repositories, - workflows: [ - .workflows[] - | { - repository, - id, - name, - path, - original_state: .state - } - ] - }' "$inventory_file" > "$temporary_snapshot" - mv "$temporary_snapshot" "$snapshot_file" - echo " ✓ wrote pre-mutation snapshot to $snapshot_file" -} - -validate_snapshot() { - local current_keys="$temporary_directory/current-keys.json" - local snapshot_keys="$temporary_directory/snapshot-keys.json" - local drift - - [ -f "$snapshot_file" ] || { echo "Snapshot file not found: $snapshot_file" >&2; return 1; } - if ! jq -e ' - .schema_version == 1 and - (.created_at | type == "string" and length > 0) and - (.organization | type == "string") and - (.source_repository | type == "string") and - (.workflows_repository | type == "string") and - (.repositories | type == "array") and - (.workflows | type == "array") and - all(.workflows[]; - (.repository | type == "string") and - (.id | type == "number") and - (.path | type == "string") and - (.original_state | type == "string"))' "$snapshot_file" >/dev/null; then - echo "Snapshot is malformed or uses an unsupported schema: $snapshot_file" >&2 - return 1 - fi - - if ! jq -e \ - --arg organization "$organization" \ - --arg source "$source_repository" \ - --arg workflows "$workflows_repository" ' - .organization == $organization and - .source_repository == $source and - .workflows_repository == $workflows' "$snapshot_file" >/dev/null; then - echo "Snapshot metadata does not match the requested organization/repositories" >&2 - return 1 - fi - - if ! diff -u \ - <(jq -S '.repositories | unique | sort' "$snapshot_file") \ - <(jq -S '.repositories | unique | sort' "$inventory_file") >/dev/null; then - echo "Snapshot repository coverage is stale or incomplete; refusing restore" >&2 - return 1 - fi - - jq -S '[.workflows[] | [.repository, .path, .id]] | sort' "$inventory_file" > "$current_keys" - jq -S '[.workflows[] | [.repository, .path, .id]] | sort' "$snapshot_file" > "$snapshot_keys" - if ! cmp -s "$current_keys" "$snapshot_keys"; then - echo "Snapshot workflow identities do not exactly match the current topology; refusing restore" >&2 - return 1 - fi - - if [ "$(jq '[.workflows[] | [.repository, .path]] | length' "$snapshot_file")" -ne \ - "$(jq '[.workflows[] | [.repository, .path]] | unique | length' "$snapshot_file")" ]; then - echo "Snapshot contains duplicate workflow paths; refusing restore" >&2 - return 1 - fi - - drift=$(jq -n -r --slurpfile snapshot "$snapshot_file" --slurpfile current "$inventory_file" ' - [ $snapshot[0].workflows[] as $saved - | $current[0].workflows[] - | select(.repository == $saved.repository and .path == $saved.path and .id == $saved.id) - | select( - if $saved.original_state == "active" - then (.state != "active" and .state != "disabled_manually") - else .state != $saved.original_state - end) - | "\(.repository) :: \(.path) (saved=\($saved.original_state), current=\(.state))" - ] | .[]') - if [ -n "$drift" ]; then - echo "Workflow state drift is incompatible with this snapshot; refusing restore:" >&2 - printf '%s\n' "$drift" | sed 's/^/ /' >&2 - return 1 - fi - if [ "$(jq '[.workflows[].active_runs[]] | length' "$inventory_file")" -ne 0 ]; then - echo "Controlled runs are still queued or running; refusing restore until the organization is quiescent" >&2 - return 1 - fi - - echo " ✓ snapshot schema, metadata, coverage, identities, state drift, and quiescence are valid" -} - -status() { - local repository - local count - local record - local active - local disabled - local other - local active_runs - - printf '\n%-36s %-22s %s\n' "REPOSITORY" "STATE" "WORKFLOW" - while IFS= read -r repository; do - count=$(jq --arg repository "$repository" \ - '[.workflows[] | select(.repository == $repository)] | length' "$inventory_file") - if [ "$count" -eq 0 ]; then - printf '%-36s %-22s %s\n' "$organization/$repository" "-" "no controlled workflows installed" - continue - fi - while IFS= read -r record; do - printf '%-36s %-22s %s (%s)\n' \ - "$organization/$repository" \ - "$(jq -r '.state' <<< "$record")" \ - "$(jq -r '.name' <<< "$record")" \ - "$(jq -r '.path' <<< "$record")" - done < <(jq -c --arg repository "$repository" \ - '.workflows[] | select(.repository == $repository)' "$inventory_file") - done < <(jq -r '.repositories[]' "$inventory_file") - - active=$(jq '[.workflows[] | select(.state == "active")] | length' "$inventory_file") - disabled=$(jq '[.workflows[] | select(.state == "disabled_manually")] | length' "$inventory_file") - other=$(jq '[.workflows[] | select(.state != "active" and .state != "disabled_manually")] | length' "$inventory_file") - active_runs=$(jq '[.workflows[].active_runs[]] | length' "$inventory_file") - echo - echo "Controlled workflows: active=$active disabled_manually=$disabled other=$other" - echo "Queued/running controlled runs: $active_runs" -} - -verify_frozen() { - local active_records - local active_runs - - require_central_workflows || { failures=$((failures + 1)); return 0; } - active_records=$(jq '[.workflows[] | select(.state == "active")] | length' "$inventory_file") - active_runs=$(jq '[.workflows[].active_runs[]] | length' "$inventory_file") - if [ "$active_records" -ne 0 ]; then - echo " ✗ freeze verification failed: $active_records controlled workflow(s) are active" >&2 - jq -r '.workflows[] | select(.state == "active") | " \(.repository) :: \(.path)"' \ - "$inventory_file" >&2 - failures=$((failures + 1)) - fi - if [ "$active_runs" -ne 0 ]; then - echo " ✗ freeze verification failed: $active_runs controlled run(s) are queued or running" >&2 - jq -r '.workflows[] as $workflow | .active_runs[] | " \($workflow.repository) :: run \(.id) (\(.status))"' \ - "$inventory_file" >&2 - failures=$((failures + 1)) - fi - if [ "$active_records" -eq 0 ] && [ "$active_runs" -eq 0 ]; then - echo " ✓ organization is frozen: all controlled workflows are inactive and no controlled runs are active" - fi -} - -disable_all_and_cancel() { - local bootstrap_record - local record - - bootstrap_record=$(workflow_record "$workflows_repository" "$bootstrap_path") - set_record_state "$bootstrap_record" "disabled_manually" - - while IFS= read -r record; do - if [ "$(jq -r '.path' <<< "$record")" = "$bootstrap_path" ]; then - continue - fi - set_record_state "$record" "disabled_manually" - done < <(jq -c '.workflows[]' "$inventory_file") - - cancel_controlled_runs - if [ "$apply" = true ]; then - wait_for_quiescence - fi -} - -freeze() { - echo - echo "Freezing AI corpus propagation and synchronization..." - require_central_workflows || { failures=$((failures + 1)); return 0; } - require_repository "$source_repository" || { failures=$((failures + 1)); return 0; } - - if [ "$apply" = true ]; then - write_snapshot || { failures=$((failures + 1)); return 0; } - elif [ -n "$snapshot_file" ]; then - echo " ~ would write the complete pre-mutation snapshot to $snapshot_file" - else - echo " - no snapshot path supplied; --snapshot is mandatory when applying" - fi - - disable_all_and_cancel -} - -restore() { - local saved - local repository - local workflow_path - local record - - echo - echo "Restoring workflow activation state from $snapshot_file..." - validate_snapshot || { failures=$((failures + 1)); return 0; } - - while IFS= read -r saved; do - repository=$(jq -r '.repository' <<< "$saved") - workflow_path=$(jq -r '.path' <<< "$saved") - record=$(workflow_record "$repository" "$workflow_path") - set_record_state "$record" "active" - done < <(jq -c '.workflows[] | select(.original_state == "active")' "$snapshot_file") - - echo " Workflows recorded as inactive were left unchanged. Canceled runs and corpus content are not restored." -} - -quiesce_for_topology_change() { - echo "Quiescing all controlled workflows before enabling the requested topology..." - disable_all_and_cancel - [ "$failures" -eq 0 ] -} - -enable_after_quiescence() { - local repository="$1" - local workflow_path="$2" - local record - - record=$(workflow_record "$repository" "$workflow_path") - set_record_state "$record" "active" "disabled_manually" -} - -enable_canary() { - local default_branch - local sync_record - local sync_id - - [ "$canary_repository" != "$workflows_repository" ] || { - echo "The reusable-workflows repository cannot be the canary" >&2 - exit 2 - } - - echo - echo "Preparing isolated PR-based synchronization canary for $organization/$canary_repository..." - require_central_workflows || { failures=$((failures + 1)); return 0; } - require_repository "$source_repository" || { failures=$((failures + 1)); return 0; } - require_repository "$canary_repository" || { failures=$((failures + 1)); return 0; } - require_workflow "$canary_repository" "$sync_path" || { failures=$((failures + 1)); return 0; } - - if ! default_branch=$(gh_api_with_retry "repos/$organization/$canary_repository" --jq '.default_branch'); then - echo "Failed to resolve the canary repository's default branch" >&2 - failures=$((failures + 1)) - return 0 - fi - if [ -z "$default_branch" ] || [ "$default_branch" = "null" ]; then - echo "The canary repository has no default branch" >&2 - failures=$((failures + 1)) - return 0 - fi - - quiesce_for_topology_change || return 0 - enable_after_quiescence "$workflows_repository" "$sync_path" - enable_after_quiescence "$canary_repository" "$sync_path" - [ "$failures" -eq 0 ] || return 0 - - sync_record=$(workflow_record "$canary_repository" "$sync_path") - sync_id=$(jq -r '.id' <<< "$sync_record") - if [ "$apply" = false ]; then - echo " ~ dispatch Sync Copilot Instructions in $organization/$canary_repository from $organization/$source_repository" - elif GH_API_RETRY_MODE=never gh_api_with_retry -X POST \ - "repos/$organization/$canary_repository/actions/workflows/$sync_id/dispatches" \ - -f "ref=$default_branch" \ - -f "inputs[source_repository]=$organization/$source_repository" >/dev/null; then - echo " ✓ dispatched canary sync; inspect its run and pull request before enabling propagation" - else - echo " ✗ failed to dispatch canary sync" >&2 - failures=$((failures + 1)) - fi -} - -enable_single_source() { - local record - local repository - local workflow_path - - echo - echo "Converging on a single authoritative propagation source..." - require_central_workflows || { failures=$((failures + 1)); return 0; } - require_repository "$source_repository" || { failures=$((failures + 1)); return 0; } - require_workflow "$source_repository" "$propagate_path" || { failures=$((failures + 1)); return 0; } - - quiesce_for_topology_change || return 0 - while IFS= read -r record; do - repository=$(jq -r '.repository' <<< "$record") - workflow_path=$(jq -r '.path' <<< "$record") - if [ "$workflow_path" = "$sync_path" ] || - { [ "$workflow_path" = "$propagate_path" ] && - { [ "$repository" = "$source_repository" ] || [ "$repository" = "$workflows_repository" ]; }; }; then - set_record_state "$record" "active" "disabled_manually" - fi - done < <(jq -c '.workflows[]' "$inventory_file") - - echo - echo "No fan-out was dispatched automatically. After verification, run:" - echo " gh workflow run propagate-copilot-instructions.yml --repo $organization/$source_repository" -} - -enable_legacy_all_to_all() { - local record - - echo - echo "WARNING: restoring the legacy multi-source all-to-all topology." - require_central_workflows || { failures=$((failures + 1)); return 0; } - quiesce_for_topology_change || return 0 - while IFS= read -r record; do - set_record_state "$record" "active" "disabled_manually" - done < <(jq -c '.workflows[]' "$inventory_file") -} - -if ! build_inventory; then - failures=$((failures + 1)) -else - case "$operation" in - status) - status - ;; - verify-frozen) - verify_frozen - ;; - freeze) - freeze - ;; - restore) - restore - ;; - canary) - enable_canary - ;; - enable-single-source) - enable_single_source - ;; - enable-legacy-all-to-all) - enable_legacy_all_to_all - ;; - esac -fi - -echo -echo "Summary: changed=$changed skipped=$skipped cancelled=$cancelled failures=$failures" -if [ "$apply" = false ] && [ "$operation" != "status" ] && [ "$operation" != "verify-frozen" ]; then - echo "Dry run only. Re-run with --apply --confirm-organization $organization to perform GitHub changes." -fi - -[ "$failures" -eq 0 ] diff --git a/.github/scripts/bootstrap-copilot-sync.sh b/.github/scripts/bootstrap-copilot-sync.sh deleted file mode 100755 index 3ba86f4..0000000 --- a/.github/scripts/bootstrap-copilot-sync.sh +++ /dev/null @@ -1,556 +0,0 @@ -#!/usr/bin/env bash -# Main logic for the Bootstrap Copilot Sync workflow. -# Called by .github/workflows/bootstrap-copilot-sync.yml after checkout. -# -# This script handles both initial bootstrap and ongoing updates for all -# Cratis repositories: -# - New repos: adds wrapper workflows and copies initial Copilot setup from Cratis/AI. -# - Already-bootstrapped repos: updates wrapper workflows and Copilot files if needed. -# - Up-to-date repos: skips processing. -# -# Expects: -# GH_TOKEN - PAT with repo + Workflows permissions; the PAT owner must -# be a bypass actor on target repos' branch protection rulesets -# so that direct pushes to the default branch are allowed. -# REPOS_FILE - path to a JSON file containing the repos array -# (written by the "Get all Cratis repositories" step) - -set -euo pipefail - -# Wrapper for gh api that retries on GitHub API rate limiting. -# Prints API response body to stdout on success. -gh_api_with_retry() { - local max_attempts=8 - local attempt=1 - local response="" - local err="" - - while [ "$attempt" -le "$max_attempts" ]; do - local out_file err_file - out_file=$(mktemp) - err_file=$(mktemp) - - if gh api "$@" >"$out_file" 2>"$err_file"; then - cat "$out_file" - rm -f "$out_file" "$err_file" - return 0 - fi - - response=$(cat "$out_file" 2>/dev/null || true) - err=$(cat "$err_file" 2>/dev/null || true) - rm -f "$out_file" "$err_file" - - if printf '%s\n%s' "$response" "$err" | grep -qiE 'API rate limit exceeded|secondary rate limit|rate_limit|abuse'; then - local wait_seconds - wait_seconds=$((attempt * 15)) - if [ "$wait_seconds" -gt 300 ]; then - wait_seconds=300 - fi - - echo " ⏳ GitHub API rate limit hit; waiting ${wait_seconds} seconds before retry (attempt $attempt/$max_attempts)" >&2 - sleep "$wait_seconds" - attempt=$((attempt + 1)) - continue - fi - - [ -n "$response" ] && echo "$response" - [ -n "$err" ] && echo "$err" >&2 - return 1 - done - - [ -n "$response" ] && echo "$response" - [ -n "$err" ] && echo "$err" >&2 - return 1 -} - -# Validate branch names to avoid treating JSON error responses as branch refs. -is_valid_branch_name() { - local branch="${1:-}" - [[ "$branch" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] && - [[ "$branch" != */ ]] && - [[ "$branch" != /* ]] && - [[ "$branch" != *. ]] && - [[ "$branch" != *..* ]] && - [[ "$branch" != *.lock ]] -} - -# Extract a SHA from a gh api JSON response. Returns empty string if: -# - the response is empty -# - the jq path does not exist -# - the value is not a valid 40-char hex SHA -# Usage: sha=$(extract_sha "$response" '.sha') -extract_sha() { - local response="$1" jq_path="${2:-.sha}" - local val - val=$(echo "$response" | jq -r "$jq_path // empty" 2>/dev/null || true) - # Validate: must look like a git SHA (40 or 64 hex chars) - if [[ "$val" =~ ^[0-9a-f]{40,64}$ ]]; then - echo "$val" - fi -} - -repos_file="${REPOS_FILE:-$GITHUB_WORKSPACE/repos.json}" -repos=$(cat "$repos_file") - -failures_file=$(mktemp) - -# Pre-computed base64 content for wrapper workflows. -# Using base64 avoids heredoc end-markers at column 0, -# which would terminate the YAML block scalar prematurely. -# -# sync_b64 decodes to: -# 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_b64="bmFtZTogU3luYyBDb3BpbG90IEluc3RydWN0aW9ucwoKb246CiAgd29ya2Zsb3dfZGlzcGF0Y2g6CiAgICBpbnB1dHM6CiAgICAgIHNvdXJjZV9yZXBvc2l0b3J5OgogICAgICAgIGRlc2NyaXB0aW9uOiAnU291cmNlIHJlcG9zaXRvcnkgKG93bmVyL3JlcG8gZm9ybWF0KScKICAgICAgICByZXF1aXJlZDogdHJ1ZQogICAgICAgIHR5cGU6IHN0cmluZwoKam9iczoKICBzeW5jOgogICAgdXNlczogQ3JhdGlzL1dvcmtmbG93cy8uZ2l0aHViL3dvcmtmbG93cy9zeW5jLWNvcGlsb3QtaW5zdHJ1Y3Rpb25zLnltbEBtYWluCiAgICB3aXRoOgogICAgICBzb3VyY2VfcmVwb3NpdG9yeTogJHt7IGlucHV0cy5zb3VyY2VfcmVwb3NpdG9yeSB9fQogICAgc2VjcmV0czogaW5oZXJpdAo=" -# -# propagate_b64 decodes to: -# name: Propagate Copilot Instructions -# on: -# push: -# branches: ["main"] -# paths: -# - ".ai/**" -# - ".claude/**" -# - ".agents/**" -# - "AGENTS.md" -# - ".github/copilot-instructions.md" -# - ".github/instructions/**" -# - ".github/agents/**" -# - ".github/skills" -# - ".github/skills/**" -# - ".github/prompts" -# - ".github/prompts/**" -# - ".github/hooks/**" -# workflow_dispatch: -# jobs: -# propagate: -# uses: Cratis/Workflows/.github/workflows/propagate-copilot-instructions.yml@main -# with: -# event_name: ${{ github.event_name }} -# secrets: inherit -propagate_b64="bmFtZTogUHJvcGFnYXRlIENvcGlsb3QgSW5zdHJ1Y3Rpb25zCgpvbjoKICBwdXNoOgogICAgYnJhbmNoZXM6IFsibWFpbiJdCiAgICBwYXRoczoKICAgICAgLSAiLmFpLyoqIgogICAgICAtICIuY2xhdWRlLyoqIgogICAgICAtICIuYWdlbnRzLyoqIgogICAgICAtICJBR0VOVFMubWQiCiAgICAgIC0gIi5naXRodWIvY29waWxvdC1pbnN0cnVjdGlvbnMubWQiCiAgICAgIC0gIi5naXRodWIvaW5zdHJ1Y3Rpb25zLyoqIgogICAgICAtICIuZ2l0aHViL2FnZW50cy8qKiIKICAgICAgLSAiLmdpdGh1Yi9za2lsbHMiCiAgICAgIC0gIi5naXRodWIvc2tpbGxzLyoqIgogICAgICAtICIuZ2l0aHViL3Byb21wdHMiCiAgICAgIC0gIi5naXRodWIvcHJvbXB0cy8qKiIKICAgICAgLSAiLmdpdGh1Yi9ob29rcy8qKiIKICB3b3JrZmxvd19kaXNwYXRjaDoKCmpvYnM6CiAgcHJvcGFnYXRlOgogICAgdXNlczogQ3JhdGlzL1dvcmtmbG93cy8uZ2l0aHViL3dvcmtmbG93cy9wcm9wYWdhdGUtY29waWxvdC1pbnN0cnVjdGlvbnMueW1sQG1haW4KICAgIHdpdGg6CiAgICAgIGV2ZW50X25hbWU6ICR7eyBnaXRodWIuZXZlbnRfbmFtZSB9fQogICAgc2VjcmV0czogaW5oZXJpdAo=" - -# Prepare the Copilot setup from Cratis/AI once; reused for every repo. -ai_copilot_source_dir=$(mktemp -d) -ai_copilot_files="" -if SOURCE_REPO="Cratis/AI" OUTPUT_DIR="$ai_copilot_source_dir" \ - bash .github/scripts/prepare-copilot-source-artifact.sh; then - ai_copilot_files=$(jq -c '.' "${ai_copilot_source_dir}/copilot-files.json" 2>/dev/null || true) -fi -if [ -z "$ai_copilot_files" ] || [ "$ai_copilot_files" = "[]" ]; then - echo "⚠ No Copilot setup files found in Cratis/AI; second commit will be skipped" -else - echo "✓ Found $(echo "$ai_copilot_files" | jq 'length') Copilot setup file(s) in Cratis/AI" -fi - -# ================================================================ -# Pre-flight: verify PAT has write permission on target repositories -# ================================================================ -probe_repo=$(echo "$repos" | jq -r '[.[] | select(. != "Workflows")][0] // empty') -if [ -n "$probe_repo" ]; then - probe_perms=$(gh_api_with_retry "repos/Cratis/$probe_repo" --jq '.permissions.push // false' 2>/dev/null || true) - if [ "$probe_perms" != "true" ]; then - echo "::error::PAT_WORKFLOWS does not have write (push) access to Cratis/$probe_repo." - echo "The fine-grained PAT must be configured with:" - echo " • Resource owner: Cratis" - echo " • Repository access: All repositories" - echo " • Permissions → Contents: Read and write" - echo " • Permissions → Workflows: Read and write" - echo "Update the PAT at: https://github.com/settings/personal-access-tokens" - exit 1 - fi - echo "✓ PAT has write access to Cratis/$probe_repo (pre-flight check passed)" -fi - -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..." - - # ---------------------------------------------------------------- - # 1. Get default branch and HEAD SHA - # ---------------------------------------------------------------- - repo_info_error=$(mktemp) - default_branch=$(gh_api_with_retry "repos/Cratis/$repo" \ - --jq '.default_branch' 2>"$repo_info_error" || true) - if [ -z "$default_branch" ]; then - repo_info_api_error=$(cat "$repo_info_error" 2>/dev/null || true) - echo " ⚠ Could not get default branch for $repo, skipping" - [ -n "$repo_info_api_error" ] && echo " API error: $repo_info_api_error" - rm -f "$repo_info_error" - continue - fi - if ! is_valid_branch_name "$default_branch"; then - echo " ⚠ Invalid default branch value for $repo ('$default_branch'), skipping" - echo " Expected a non-empty ref-safe branch name (no leading/trailing '/', no trailing '.', no '..', no '.lock')." - rm -f "$repo_info_error" - continue - fi - rm -f "$repo_info_error" - - head_sha_error=$(mktemp) - _head_sha_resp=$(gh_api_with_retry "repos/Cratis/$repo/git/ref/heads/$default_branch" \ - 2>"$head_sha_error" || true) - head_sha=$(extract_sha "$_head_sha_resp" '.object.sha') - if [ -z "$head_sha" ]; then - head_sha_api_error=$(cat "$head_sha_error" 2>/dev/null || true) - echo " ⚠ Could not get HEAD SHA for $repo ($default_branch branch not found), skipping" - [ -n "$head_sha_api_error" ] && echo " API error: $head_sha_api_error" - rm -f "$head_sha_error" - continue - fi - rm -f "$head_sha_error" - - # ---------------------------------------------------------------- - # 2. Get the commit's tree SHA - # ---------------------------------------------------------------- - tree_sha_error=$(mktemp) - _tree_sha_resp=$(gh_api_with_retry "repos/Cratis/$repo/git/commits/$head_sha" \ - 2>"$tree_sha_error" || true) - tree_sha=$(extract_sha "$_tree_sha_resp" '.tree.sha') - if [ -z "$tree_sha" ]; then - tree_sha_api_error=$(cat "$tree_sha_error" 2>/dev/null || true) - echo " ⚠ Could not get tree SHA for $repo, skipping" - [ -n "$tree_sha_api_error" ] && echo " API error: $tree_sha_api_error" - rm -f "$tree_sha_error" - continue - fi - rm -f "$tree_sha_error" - - # ---------------------------------------------------------------- - # 3. Get the full recursive tree to find instruction files to delete - # ---------------------------------------------------------------- - subtree_error=$(mktemp) - subtree=$(gh_api_with_retry "repos/Cratis/$repo/git/trees/$tree_sha?recursive=1" \ - 2>"$subtree_error" || true) - if [ -z "$subtree" ]; then - subtree_api_error=$(cat "$subtree_error" 2>/dev/null || true) - echo " ⚠ Could not get tree for $repo, skipping" - [ -n "$subtree_api_error" ] && echo " API error: $subtree_api_error" - rm -f "$subtree_error" - continue - fi - rm -f "$subtree_error" - - # ---------------------------------------------------------------- - # 4. Create blobs for the two workflow files - # Blobs are raw content — no path-level permission checks here. - # The Workflows permission is checked later when creating the tree. - # ---------------------------------------------------------------- - sync_blob_error=$(mktemp) - _sync_blob_resp=$(gh_api_with_retry -X POST "repos/Cratis/$repo/git/blobs" \ - -f content="$sync_b64" -f encoding=base64 \ - 2>"$sync_blob_error" || true) - sync_blob_sha=$(extract_sha "$_sync_blob_resp") - propagate_blob_error=$(mktemp) - _prop_blob_resp=$(gh_api_with_retry -X POST "repos/Cratis/$repo/git/blobs" \ - -f content="$propagate_b64" -f encoding=base64 \ - 2>"$propagate_blob_error" || true) - propagate_blob_sha=$(extract_sha "$_prop_blob_resp") - - if [ -z "$sync_blob_sha" ] || [ -z "$propagate_blob_sha" ]; then - echo " ⚠ Could not create blobs for $repo" - if [ -z "$sync_blob_sha" ]; then - sync_err=$(cat "$sync_blob_error" 2>/dev/null || true) - [ -n "$sync_err" ] && echo " sync blob error: $sync_err" - fi - if [ -z "$propagate_blob_sha" ]; then - prop_err=$(cat "$propagate_blob_error" 2>/dev/null || true) - [ -n "$prop_err" ] && echo " propagate blob error: $prop_err" - fi - rm -f "$sync_blob_error" "$propagate_blob_error" - echo "$repo" >> "$failures_file" - continue - fi - rm -f "$sync_blob_error" "$propagate_blob_error" - - # ---------------------------------------------------------------- - # 5. Check if workflow files already match and no files need removal - # ---------------------------------------------------------------- - # Retrieve the current blob SHAs of the two managed workflow files - # (empty string if the files do not yet exist in the repo). - existing_sync=$(echo "$subtree" | jq -r \ - '.tree[] | select(.path == ".github/workflows/sync-copilot-instructions.yml") | .sha' \ - 2>/dev/null || true) - existing_propagate=$(echo "$subtree" | jq -r \ - '.tree[] | select(.path == ".github/workflows/propagate-copilot-instructions.yml") | .sha' \ - 2>/dev/null || true) - - # List all blob paths under .github/ that belong to Copilot instruction - # artefacts we want to remove: the root instructions file, plus the - # instructions/, agents/, skills/, prompts/, and hooks/ sub-directories. - # .agents/PROJECT.md is deliberately never listed — it is the repository's - # own project-local context, not a synced artifact, so it must survive both - # propagation and cleanup. - files_to_delete=$(echo "$subtree" | jq -r \ - '.tree[] | select(.type == "blob") | - select(.path | test("^(AGENTS\\.md$|\\.agents(/|$)|\\.github/(copilot-instructions\\.md$|instructions(/|$)|agents(/|$)|skills(/|$)|prompts(/|$)|hooks(/|$))|\\.ai/|\\.claude/)")) | - select(.path != ".claude/settings.local.json" and .path != ".agents/PROJECT.md") | - .path' 2>/dev/null || true) - - # Check whether Copilot files from Cratis/AI are already present in - # this repo with matching blob SHAs (git blob SHAs are content-addressed, - # so identical content yields identical SHAs across repositories). - ai_files_up_to_date=true - if [ -n "$ai_copilot_files" ] && [ "$ai_copilot_files" != "[]" ]; then - while IFS=$'\t' read -r ai_chk_path ai_chk_sha ai_chk_mode; do - [ -z "$ai_chk_path" ] && continue - existing_ai_sha=$(echo "$subtree" | jq -r \ - --arg p "$ai_chk_path" \ - '.tree[] | select(.path == $p) | .sha // empty' 2>/dev/null || true) - existing_ai_mode=$(echo "$subtree" | jq -r \ - --arg p "$ai_chk_path" \ - '.tree[] | select(.path == $p) | .mode // empty' 2>/dev/null || true) - if [ "$existing_ai_sha" != "$ai_chk_sha" ] || [ "$existing_ai_mode" != "$ai_chk_mode" ]; then - ai_files_up_to_date=false - break - fi - done <<< "$(echo "$ai_copilot_files" | jq -r '.[] | .path + "\t" + .sha + "\t" + (.mode // "100644")' 2>/dev/null || true)" - fi - - # Check whether any copilot files in the repo need to be cleaned up — - # i.e., files that match the delete pattern but are not part of the - # expected AI file set (with the correct SHA). If AI is up-to-date and - # every file in files_to_delete is already covered by the AI set, there - # is nothing to delete; otherwise at least one file needs removal. - # - # Pre-build tab-separated "path\tsha" lookup tables once to avoid - # repeated jq invocations inside the loop. - repo_copilot_shas=$(echo "$subtree" | jq -r \ - '[.tree[] | select(.type == "blob") | - select(.path | test("^(AGENTS\\.md$|\\.agents(/|$)|\\.github/(copilot-instructions\\.md$|instructions(/|$)|agents(/|$)|skills(/|$)|prompts(/|$)|hooks(/|$))|\\.ai/|\\.claude/)")) | - select(.path != ".claude/settings.local.json" and .path != ".agents/PROJECT.md")] | - .[] | .path + "\t" + .sha + "\t" + .mode' 2>/dev/null || true) - ai_path_sha_set=$(echo "$ai_copilot_files" | jq -r '.[] | .path + "\t" + .sha + "\t" + (.mode // "100644")' 2>/dev/null || true) - - has_files_to_clean=false - while IFS= read -r del_path; do - [ -z "$del_path" ] && continue - del_sha_mode=$(printf '%s' "$repo_copilot_shas" | awk -F'\t' -v p="$del_path" '$1==p{print $2 "\t" $3;exit}') - [ -z "$del_sha_mode" ] && continue - if ! printf '%s' "$ai_path_sha_set" | grep -qF "$del_path"$'\t'"$del_sha_mode"; then - has_files_to_clean=true - break - fi - done <<< "$files_to_delete" - - if [ "$existing_sync" = "$sync_blob_sha" ] && \ - [ "$existing_propagate" = "$propagate_blob_sha" ] && \ - [ "$has_files_to_clean" = "false" ] && \ - [ "$ai_files_up_to_date" = "true" ]; then - echo " ℹ No changes needed for $repo" - continue - fi - - # ---------------------------------------------------------------- - # 6. Build the new tree JSON - # - Add the two workflow files (with their blob SHAs) - # - Delete instruction files by setting sha to null - # ---------------------------------------------------------------- - new_tree_json=$(jq -n \ - --arg base_tree "$tree_sha" \ - --arg sync_path ".github/workflows/sync-copilot-instructions.yml" \ - --arg sync_sha "$sync_blob_sha" \ - --arg prop_path ".github/workflows/propagate-copilot-instructions.yml" \ - --arg prop_sha "$propagate_blob_sha" \ - '{ - base_tree: $base_tree, - tree: [ - {path: $sync_path, mode: "100644", type: "blob", sha: $sync_sha}, - {path: $prop_path, mode: "100644", type: "blob", sha: $prop_sha} - ] - }') - - # Append deletion entries for each instruction file found - while IFS= read -r file; do - [ -z "$file" ] && continue - new_tree_json=$(echo "$new_tree_json" | jq \ - --arg p "$file" \ - '.tree += [{path: $p, mode: "100644", type: "blob", sha: null}]') - done <<< "$files_to_delete" - - # ---------------------------------------------------------------- - # 7. Create the new tree object - # ---------------------------------------------------------------- - tree_error=$(mktemp) - _new_tree_resp=$(echo "$new_tree_json" | \ - gh api -X POST "repos/Cratis/$repo/git/trees" \ - --input - 2>"$tree_error" || true) - new_tree_sha=$(extract_sha "$_new_tree_resp") - - if [ -z "$new_tree_sha" ]; then - tree_api_error=$(cat "$tree_error" 2>/dev/null || true) - echo " ⚠ Could not create tree for $repo" - if echo "$tree_api_error" | grep -qi '403'; then - echo " API error: $tree_api_error" - echo " → PAT lacks 'Contents: Read and write' for this repo." - echo " → Update PAT repository access at https://github.com/settings/personal-access-tokens" - else - [ -n "$tree_api_error" ] && echo " API error: $tree_api_error" - fi - rm -f "$tree_error" - echo "$repo" >> "$failures_file" - continue - fi - rm -f "$tree_error" - - # ---------------------------------------------------------------- - # 8. Create the commit - # ---------------------------------------------------------------- - commit_error=$(mktemp) - _commit_resp=$(jq -n \ - --arg msg "Bootstrap Copilot sync workflows" \ - --arg tree "$new_tree_sha" \ - --arg parent "$head_sha" \ - '{"message": $msg, "tree": $tree, "parents": [$parent]}' | \ - gh api -X POST "repos/Cratis/$repo/git/commits" \ - --input - 2>"$commit_error" || true) - new_commit_sha=$(extract_sha "$_commit_resp") - - if [ -z "$new_commit_sha" ]; then - commit_api_error=$(cat "$commit_error" 2>/dev/null || true) - echo " ⚠ Could not create commit for $repo" - [ -n "$commit_api_error" ] && echo " API error: $commit_api_error" - rm -f "$commit_error" - echo "$repo" >> "$failures_file" - continue - fi - rm -f "$commit_error" - - # ---------------------------------------------------------------- - # 9. Copy Copilot setup from Cratis/AI (second commit) - # ---------------------------------------------------------------- - if [ -n "$ai_copilot_files" ] && [ "$ai_copilot_files" != "[]" ]; then - ai_second_tree_json=$(jq -n \ - --arg base_tree "$new_tree_sha" \ - '{"base_tree": $base_tree, "tree": []}') - - ai_copy_failed=false - while IFS=$'\t' read -r ai_path ai_sha ai_mode; do - [ -z "$ai_path" ] && continue - - ai_blob_file="${ai_copilot_source_dir}/blobs/${ai_sha}.b64" - if [ ! -f "$ai_blob_file" ]; then - echo " ⚠ Prepared source artifact is missing blob for $ai_path ($ai_sha); skipping second commit" - ai_copy_failed=true - break - fi - - clean_ai_b64=$(tr -d '\n' < "$ai_blob_file") - - target_blob_error=$(mktemp) - _target_blob_resp=$(gh_api_with_retry -X POST "repos/Cratis/$repo/git/blobs" \ - -f "content=$clean_ai_b64" \ - -f encoding=base64 \ - 2>"$target_blob_error" || true) - target_blob_sha=$(extract_sha "$_target_blob_resp") - - if [ -z "$target_blob_sha" ]; then - target_blob_api_error=$(cat "$target_blob_error" 2>/dev/null || true) - echo " ⚠ Could not create blob for $ai_path in $repo; skipping second commit" - [ -n "$target_blob_api_error" ] && echo " API error: $target_blob_api_error" - rm -f "$target_blob_error" - ai_copy_failed=true - break - fi - rm -f "$target_blob_error" - - ai_second_tree_json=$(echo "$ai_second_tree_json" | jq \ - --arg p "$ai_path" \ - --arg s "$target_blob_sha" \ - --arg m "$ai_mode" \ - '.tree += [{path: $p, mode: $m, type: "blob", sha: $s}]') - done <<< "$(echo "$ai_copilot_files" | jq -r '.[] | .path + "\t" + .sha + "\t" + (.mode // "100644")' 2>/dev/null || true)" - - if [ "$ai_copy_failed" = "false" ]; then - ai_second_tree_error=$(mktemp) - _ai_tree_resp=$(echo "$ai_second_tree_json" | \ - gh api -X POST "repos/Cratis/$repo/git/trees" \ - --input - 2>"$ai_second_tree_error" || true) - ai_second_tree_sha=$(extract_sha "$_ai_tree_resp") - - if [ -z "$ai_second_tree_sha" ]; then - ai_second_tree_api_error=$(cat "$ai_second_tree_error" 2>/dev/null || true) - echo " ⚠ Could not create second tree for $repo; will push first commit only" - [ -n "$ai_second_tree_api_error" ] && echo " API error: $ai_second_tree_api_error" - else - ai_second_commit_error=$(mktemp) - _ai_commit_resp=$(jq -n \ - --arg msg "Add initial Copilot setup from Cratis/AI" \ - --arg tree "$ai_second_tree_sha" \ - --arg parent "$new_commit_sha" \ - '{"message": $msg, "tree": $tree, "parents": [$parent]}' | \ - gh api -X POST "repos/Cratis/$repo/git/commits" \ - --input - 2>"$ai_second_commit_error" || true) - ai_second_commit_sha=$(extract_sha "$_ai_commit_resp") - - if [ -z "$ai_second_commit_sha" ]; then - ai_second_commit_api_error=$(cat "$ai_second_commit_error" 2>/dev/null || true) - echo " ⚠ Could not create second commit for $repo; will push first commit only" - [ -n "$ai_second_commit_api_error" ] && echo " API error: $ai_second_commit_api_error" - else - echo " ✓ Added Copilot setup from Cratis/AI (second commit)" - new_commit_sha="$ai_second_commit_sha" - fi - rm -f "$ai_second_commit_error" - fi - rm -f "$ai_second_tree_error" - fi - fi - - # ---------------------------------------------------------------- - # 10. Push commit directly to the default branch - # - # A fast-forward (non-force) PATCH updates the ref only if the new - # commit is a descendant of the current HEAD — safe against races. - # The PAT owner must be configured as a bypass actor on the target - # repository's branch protection ruleset for this push to succeed. - # ---------------------------------------------------------------- - push_error=$(mktemp) - push_result=$(gh_api_with_retry -X PATCH "repos/Cratis/$repo/git/refs/heads/$default_branch" \ - -f sha="$new_commit_sha" \ - -F force=false \ - 2>"$push_error" || true) - updated_sha=$(extract_sha "$push_result" '.object.sha') - - if [ -z "$updated_sha" ]; then - push_api_error=$(cat "$push_error" 2>/dev/null || true) - push_msg=$(echo "$push_result" | jq -r '.message // empty' 2>/dev/null || true) - echo " ⚠ Could not push commit to $default_branch in $repo" - [ -n "$push_api_error" ] && echo " API error: $push_api_error" - [ -n "$push_msg" ] && echo " GitHub message: $push_msg" - rm -f "$push_error" - echo "$repo" >> "$failures_file" - continue - fi - rm -f "$push_error" - - echo " ✓ Pushed Bootstrap Copilot sync workflows directly to $default_branch in $repo" -done - -total_failures=$(wc -l < "$failures_file" 2>/dev/null || echo "0") -rm -f "$failures_file" - -echo "" -echo "Summary: $total_failures failure(s)" - -if [ "$total_failures" -gt 0 ]; then - echo "::error::$total_failures repo(s) failed. Check the log above for details." - exit 1 -fi diff --git a/.github/scripts/copilot-sync-ignore-filter.sh b/.github/scripts/copilot-sync-ignore-filter.sh deleted file mode 100644 index 5a5f421..0000000 --- a/.github/scripts/copilot-sync-ignore-filter.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# Shared helper: filters copilot_files JSON array using patterns from -# .github/.copilot-sync-ignore in the source repository tree. -# -# Expects the following variables to be set by the caller: -# source_tree_raw - full recursive tree JSON from the source repo -# source_repo - source repository in owner/repo format -# copilot_files - JSON array of {path, sha} objects -# -# After sourcing, copilot_files will be updated in place (filtered). -# Returns 1 if all files are excluded (caller should handle the exit). - -_apply_copilot_sync_ignore() { - local ignore_sha - ignore_sha=$(echo "$source_tree_raw" | jq -r \ - '.tree[] | select(.path == ".github/.copilot-sync-ignore") | .sha // empty' \ - 2>/dev/null || true) - - [ -z "$ignore_sha" ] && return 0 - - echo "ℹ Found .copilot-sync-ignore in ${source_repo}" - local ignore_blob - if declare -F gh_api_with_retry >/dev/null 2>&1; then - ignore_blob=$(gh_api_with_retry "repos/${source_repo}/git/blobs/${ignore_sha}" \ - --jq '.content' 2>/dev/null || true) - else - ignore_blob=$(gh api "repos/${source_repo}/git/blobs/${ignore_sha}" \ - --jq '.content' 2>/dev/null || true) - fi - local ignore_content - ignore_content=$(echo "$ignore_blob" | base64 -d 2>/dev/null || true) - - [ -z "$ignore_content" ] && return 0 - - # Build a combined regex from all non-comment, non-empty lines. - # Each glob pattern is converted to a regex: - # ** → .* (match across directories) - # * → [^/]* (match within a single directory) - # ? → [^/] (match a single character) - # . → \. (literal dot) - # Patterns without an explicit root prefix default to .github/ for backward - # compatibility. Use .ai/, .claude/, .agents/, or AGENTS.md explicitly to - # target those propagated AI surfaces. - local combined_regex="" - local pattern regex - while IFS= read -r pattern || [ -n "$pattern" ]; do - pattern=$(echo "$pattern" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - [ -z "$pattern" ] && continue - [[ "$pattern" == \#* ]] && continue - - # Normalize: default unscoped patterns to .github/ for backward compatibility. - if [[ "$pattern" != .github/* && \ - "$pattern" != .ai/* && \ - "$pattern" != .claude/* && \ - "$pattern" != .agents/* && \ - "$pattern" != AGENTS.md ]]; then - pattern=".github/${pattern}" - fi - - # Convert glob → regex (order matters: ** before *) - regex=$(printf '%s' "$pattern" \ - | sed -e 's/\*\*/__GLOBSTAR__/g' \ - -e 's/\*/__STAR__/g' \ - -e 's/\./\\./g' \ - -e 's|?|[^/]|g' \ - -e 's/__GLOBSTAR__/.*/g' \ - -e 's/__STAR__/[^\/]*/g') - - if [ -n "$combined_regex" ]; then - combined_regex="${combined_regex}|^${regex}$" - else - combined_regex="^${regex}$" - fi - done <<< "$ignore_content" - - [ -z "$combined_regex" ] && return 0 - - local before_count after_count excluded - before_count=$(echo "$copilot_files" | jq 'length') - copilot_files=$(echo "$copilot_files" | jq -c \ - --arg regex "$combined_regex" \ - '[.[] | select(.path | test($regex) | not)]') - after_count=$(echo "$copilot_files" | jq 'length') - excluded=$((before_count - after_count)) - - if [ "$excluded" -gt 0 ]; then - echo " Excluded ${excluded} file(s) matching .copilot-sync-ignore patterns" - fi - - if [ "$copilot_files" = "[]" ]; then - return 1 - fi - - echo "✓ After filtering: ${after_count} file(s) remaining" -} diff --git a/.github/scripts/github-api-retry.sh b/.github/scripts/github-api-retry.sh deleted file mode 100755 index 4287dad..0000000 --- a/.github/scripts/github-api-retry.sh +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) Cratis. All rights reserved. -# Licensed under the MIT license. See LICENSE file in the project root for full license information. -# -# Shared gh api wrapper with bounded retry handling for rate limits and transient failures. -# Source this file from scripts that already run with set -euo pipefail. -# The wrapper preserves gh api stdout on success and stderr on terminal failure. - -_gh_api_retry_after_seconds() { - local text="$1" - local retry_after - local reset_epoch - local now - - retry_after=$(printf '%s\n' "$text" | awk ' - BEGIN { IGNORECASE = 1 } - /^[[:space:]]*retry-after:[[:space:]]*[0-9]+/ { - gsub("\r", "") - print $2 - exit - }') - if [[ "$retry_after" =~ ^[0-9]+$ ]]; then - echo "$retry_after" - return 0 - fi - - reset_epoch=$(printf '%s\n' "$text" | awk ' - BEGIN { IGNORECASE = 1 } - /^[[:space:]]*x-ratelimit-reset:[[:space:]]*[0-9]+/ { - gsub("\r", "") - print $2 - exit - }') - if [[ "$reset_epoch" =~ ^[0-9]+$ ]]; then - now=$(date +%s) - if [ "$reset_epoch" -gt "$now" ]; then - echo $((reset_epoch - now + 1)) - else - echo 1 - fi - return 0 - fi - - return 1 -} - -_gh_api_is_rate_limited() { - local text="$1" - - printf '%s\n' "$text" | grep -qiE \ - 'API rate limit exceeded|secondary rate limit|rate_limit|abuse detection|Retry-After|x-ratelimit-reset|exceeded a secondary rate limit' -} - -_gh_api_is_transient_failure() { - local text="$1" - - printf '%s\n' "$text" | grep -qiE \ - 'HTTP (5[0-9]{2})|status code (5[0-9]{2})|500 Internal Server Error|502 Bad Gateway|503 Service Unavailable|504 Gateway Timeout|connection (reset|refused|closed)|connection timed out|operation timed out|context deadline exceeded|TLS handshake timeout|temporary failure|unexpected EOF|stream error' -} - -_gh_api_retry_label() { - local text="$1" - - if _gh_api_is_rate_limited "$text"; then - echo "rate limit" - else - echo "transient failure" - fi -} - -gh_api_with_retry() { - local max_attempts="${GH_API_MAX_ATTEMPTS:-8}" - local base_delay="${GH_API_BASE_DELAY_SECONDS:-15}" - local max_delay="${GH_API_MAX_DELAY_SECONDS:-300}" - local max_jitter="${GH_API_MAX_JITTER_SECONDS:-5}" - local retry_mode="${GH_API_RETRY_MODE:-auto}" - local attempt=1 - local response="" - local err="" - local cached_input_file="" - local args=("$@") - local i - - [[ "$max_attempts" =~ ^[0-9]+$ ]] || max_attempts=8 - [[ "$base_delay" =~ ^[0-9]+$ ]] || base_delay=15 - [[ "$max_delay" =~ ^[0-9]+$ ]] || max_delay=300 - [[ "$max_jitter" =~ ^[0-9]+$ ]] || max_jitter=5 - [ "$max_attempts" -gt 0 ] || max_attempts=1 - - # gh api --input - consumes stdin. Cache it once so retries can replay it. - for ((i = 0; i < ${#args[@]}; i++)); do - if [ "${args[$i]}" = "--input" ]; then - local next_index=$((i + 1)) - if [ "$next_index" -lt "${#args[@]}" ] && [ "${args[$next_index]}" = "-" ]; then - cached_input_file=$(mktemp) - cat > "$cached_input_file" - args[next_index]="$cached_input_file" - break - fi - elif [ "${args[$i]}" = "--input=-" ]; then - cached_input_file=$(mktemp) - cat > "$cached_input_file" - args[i]="--input=${cached_input_file}" - break - fi - done - - while [ "$attempt" -le "$max_attempts" ]; do - local out_file - local err_file - local combined - local wait_seconds - local retry_label - local jitter=0 - - out_file=$(mktemp) - err_file=$(mktemp) - - if gh api "${args[@]}" >"$out_file" 2>"$err_file"; then - cat "$out_file" - rm -f "$out_file" "$err_file" "$cached_input_file" - return 0 - fi - - response=$(cat "$out_file" 2>/dev/null || true) - err=$(cat "$err_file" 2>/dev/null || true) - rm -f "$out_file" "$err_file" - combined=$(printf '%s\n%s' "$response" "$err") - - if [ "$retry_mode" != "never" ] && - { _gh_api_is_rate_limited "$combined" || _gh_api_is_transient_failure "$combined"; } && - [ "$attempt" -lt "$max_attempts" ]; then - wait_seconds=$(_gh_api_retry_after_seconds "$combined" || true) - if [ -z "$wait_seconds" ]; then - wait_seconds=$((attempt * base_delay)) - fi - if [ "$max_jitter" -gt 0 ]; then - jitter=$((RANDOM % (max_jitter + 1))) - wait_seconds=$((wait_seconds + jitter)) - fi - if [ "$wait_seconds" -gt "$max_delay" ]; then - wait_seconds="$max_delay" - fi - if [ "$wait_seconds" -lt 1 ]; then - wait_seconds=1 - fi - - retry_label=$(_gh_api_retry_label "$combined") - echo " GitHub API $retry_label; waiting ${wait_seconds} seconds before retry (attempt ${attempt}/${max_attempts})" >&2 - sleep "$wait_seconds" - attempt=$((attempt + 1)) - continue - fi - - [ -n "$response" ] && echo "$response" - [ -n "$err" ] && echo "$err" >&2 - rm -f "$cached_input_file" - return 1 - done - - [ -n "$response" ] && echo "$response" - [ -n "$err" ] && echo "$err" >&2 - rm -f "$cached_input_file" - return 1 -} diff --git a/.github/scripts/prepare-copilot-source-artifact.sh b/.github/scripts/prepare-copilot-source-artifact.sh deleted file mode 100755 index fa1d759..0000000 --- a/.github/scripts/prepare-copilot-source-artifact.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env bash -# Fetches Copilot source files once and writes a reusable artifact directory. -# -# Expects: -# GH_TOKEN - PAT with read access to SOURCE_REPO -# SOURCE_REPO - source repository in owner/repo format -# OUTPUT_DIR - directory to populate with copilot-files.json and blobs/*.b64 -# -# The artifact normalizes known adapter paths so broadcast propagation keeps the -# Cratis AI corpus shape intact. A source repository may already contain copied -# content at an adapter path; propagation should repair that back to an adapter -# when the matching canonical .ai file exists in the source tree. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=github-api-retry.sh -source "${SCRIPT_DIR}/github-api-retry.sh" - -source_repo="${SOURCE_REPO:?SOURCE_REPO must be set}" -output_dir="${OUTPUT_DIR:?OUTPUT_DIR must be set}" -blobs_dir="${output_dir}/blobs" - -source_tree_has_path() { - local path="$1" - - echo "$source_tree_raw" | jq -e \ - --arg p "$path" \ - '.tree[] | select(.path == $p)' >/dev/null 2>&1 -} - -source_tree_has_prefix() { - local prefix="$1" - - echo "$source_tree_raw" | jq -e \ - --arg p "$prefix" \ - '.tree[] | select(.path | startswith($p))' >/dev/null 2>&1 -} - -adapter_spec_for_path() { - local path="$1" - local file name target - - case "$path" in - AGENTS.md) - source_tree_has_path ".ai/rules/general.md" && printf '120000\t.ai/rules/general.md\n' - ;; - .github/copilot-instructions.md) - source_tree_has_path ".ai/rules/general.md" && printf '100644\t../.ai/rules/general.md\n' - ;; - .claude/CLAUDE.md) - source_tree_has_path ".ai/rules/general.md" && printf '120000\t../.ai/rules/general.md\n' - ;; - .agents/skills) - source_tree_has_prefix ".ai/skills/" && printf '120000\t../.ai/skills\n' - ;; - .github/prompts) - source_tree_has_prefix ".ai/prompts/" && printf '120000\t../.ai/prompts\n' - ;; - .github/skills) - source_tree_has_prefix ".ai/skills/" && printf '120000\t../.ai/skills\n' - ;; - .claude/agents) - source_tree_has_prefix ".ai/agents/" && printf '120000\t../.ai/agents\n' - ;; - .claude/skills) - source_tree_has_prefix ".ai/skills/" && printf '120000\t../.ai/skills\n' - ;; - .github/instructions/*.instructions.md) - file="${path##*/}" - name="${file%.instructions.md}" - target=".ai/rules/${name}.md" - source_tree_has_path "$target" && printf '100644\t../../.ai/rules/%s.md\n' "$name" - ;; - .claude/rules/*.md) - file="${path##*/}" - name="${file%.md}" - target=".ai/rules/${name}.md" - source_tree_has_path "$target" && printf '120000\t../../.ai/rules/%s.md\n' "$name" - ;; - .github/agents/*.agent.md) - file="${path##*/}" - name="${file%.agent.md}" - target=".ai/agents/${name}.md" - source_tree_has_path "$target" && printf '120000\t../../.ai/agents/%s.md\n' "$name" - ;; - .claude/commands/*.md) - file="${path##*/}" - name="${file%.md}" - target=".ai/prompts/${name}.prompt.md" - source_tree_has_path "$target" && printf '120000\t../../.ai/prompts/%s.prompt.md\n' "$name" - ;; - .pi/prompts/*.md) - # Pi slash-command adapter: .pi/prompts/.md -> ../../.ai/prompts/.prompt.md - file="${path##*/}" - name="${file%.md}" - target=".ai/prompts/${name}.prompt.md" - source_tree_has_path "$target" && printf '120000\t../../.ai/prompts/%s.prompt.md\n' "$name" - ;; - .pi/agents/*.md) - # Pi subagent adapter: .pi/agents/.md -> ../../.ai/agents/.md - file="${path##*/}" - name="${file%.md}" - target=".ai/agents/${name}.md" - source_tree_has_path "$target" && printf '120000\t../../.ai/agents/%s.md\n' "$name" - ;; - # .pi/extensions/** are REAL adapter machinery (the Pi peer of .claude/settings.json), - # not symlink adapters: with no case here they fall through and propagate as content. - esac -} - -write_synthetic_blob() { - local content="$1" - local sha - - sha=$(printf '%s' "$content" | git hash-object --stdin) - printf '%s' "$content" | base64 | tr -d '\n' > "${blobs_dir}/${sha}.b64" - printf '%s' "$sha" -} - -normalize_adapter_entries() { - local normalized='[]' - local file_path file_sha file_mode spec adapter_mode adapter_target adapter_sha - - while IFS=$'\t' read -r file_path file_sha file_mode; do - [ -z "$file_path" ] && continue - - spec=$(adapter_spec_for_path "$file_path" || true) - if [ -n "$spec" ]; then - IFS=$'\t' read -r adapter_mode adapter_target <<< "$spec" - adapter_sha=$(write_synthetic_blob "$adapter_target") - echo "Normalized adapter ${file_path} -> ${adapter_target}" >&2 - normalized=$(echo "$normalized" | jq -c \ - --arg p "$file_path" \ - --arg s "$adapter_sha" \ - --arg m "$adapter_mode" \ - '. + [{path: $p, sha: $s, mode: $m}]') - continue - fi - - normalized=$(echo "$normalized" | jq -c \ - --arg p "$file_path" \ - --arg s "$file_sha" \ - --arg m "${file_mode:-100644}" \ - '. + [{path: $p, sha: $s, mode: $m}]') - done <<< "$(echo "$copilot_files" | jq -r '.[] | .path + "\t" + .sha + "\t" + (.mode // "100644")' 2>/dev/null || true)" - - echo "$normalized" -} - -mkdir -p "$blobs_dir" - -echo "Fetching Copilot instruction files from ${source_repo}..." -source_tree_raw=$(gh_api_with_retry "repos/${source_repo}/git/trees/HEAD?recursive=1") - -if [ -z "$source_tree_raw" ]; then - echo "::error::Could not fetch tree from ${source_repo}" - exit 1 -fi - -printf '%s' "$source_tree_raw" > "${output_dir}/source-tree.json" - -# .agents/PROJECT.md is never synced. It is by definition the project-local -# instruction file — the one place a repository records what is true only of -# itself (its endpoints, credentials, deployment recipes, issue tracker) — so -# propagating it overwrites every repository's context with whichever repo -# happened to push last. Excluded here rather than left to each repository's -# .copilot-sync-ignore, because that file only protects the repo it lives in: -# a single repo without one is enough to clobber all the others. -copilot_files=$(echo "$source_tree_raw" | jq -c \ - '[.tree[] | select(.type == "blob") | - select(.path | test("^(AGENTS\\.md$|\\.agents(/|$)|\\.github/(copilot-instructions\\.md$|instructions(/|$)|agents(/|$)|skills(/|$)|prompts(/|$)|hooks(/|$))|\\.ai/|\\.claude/|\\.pi/)")) | - select(.path != ".claude/settings.local.json" and .path != ".agents/PROJECT.md") | - {path: .path, sha: .sha, mode: .mode}]' 2>/dev/null || true) - -if [ -z "$copilot_files" ] || [ "$copilot_files" = "[]" ]; then - echo "No Copilot instruction files found in ${source_repo}." - copilot_files="[]" -else - echo "Found $(echo "$copilot_files" | jq 'length') Copilot file(s) in ${source_repo}" -fi - -# shellcheck source=copilot-sync-ignore-filter.sh -source "${SCRIPT_DIR}/copilot-sync-ignore-filter.sh" - -if [ "$copilot_files" != "[]" ]; then - if ! _apply_copilot_sync_ignore; then - echo "All Copilot files excluded by .copilot-sync-ignore." - copilot_files="[]" - fi -fi - -if [ "$copilot_files" != "[]" ]; then - copilot_files=$(normalize_adapter_entries) -fi - -printf '%s' "$copilot_files" > "${output_dir}/copilot-files.json" - -file_count=$(echo "$copilot_files" | jq 'length') - -if [ "$file_count" -gt 0 ]; then - echo "$copilot_files" | jq -r '.[].sha' | sort -u | while read -r src_sha; do - [ -z "$src_sha" ] && continue - - blob_file="${blobs_dir}/${src_sha}.b64" - [ -f "$blob_file" ] && continue - - blob_resp=$(gh_api_with_retry "repos/${source_repo}/git/blobs/${src_sha}") - if [ -z "$blob_resp" ]; then - echo "::error::Could not fetch source blob ${src_sha} from ${source_repo}" - exit 1 - fi - - encoding=$(echo "$blob_resp" | jq -r '.encoding // empty' 2>/dev/null || true) - if [ "$encoding" != "base64" ]; then - echo "::error::Unexpected encoding for source blob ${src_sha}: ${encoding:-missing}" - exit 1 - fi - - blob_content=$(echo "$blob_resp" | jq -r '.content // empty' 2>/dev/null || true) - printf '%s' "$blob_content" | tr -d '\n' > "$blob_file" - done -fi - -blob_count=$(find "$blobs_dir" -type f -name '*.b64' | wc -l | tr -d ' ') - -jq -n \ - --arg source_repository "$source_repo" \ - --argjson file_count "$file_count" \ - --argjson blob_count "$blob_count" \ - '{source_repository: $source_repository, file_count: $file_count, blob_count: $blob_count}' \ - > "${output_dir}/metadata.json" - -echo "Prepared Copilot source artifact with ${file_count} file(s) and ${blob_count} blob(s)." diff --git a/.github/scripts/propagate-copilot-instructions.sh b/.github/scripts/propagate-copilot-instructions.sh deleted file mode 100755 index f779c3b..0000000 --- a/.github/scripts/propagate-copilot-instructions.sh +++ /dev/null @@ -1,301 +0,0 @@ -#!/usr/bin/env bash -# Propagates Copilot instruction files from the source repository to a single -# target repository in the Cratis organization, pushing the commit directly to -# the default branch (no PR). -# Called by .github/workflows/propagate-copilot-instructions.yml for each -# matrix job (one per target repository). -# -# Expects: -# GH_TOKEN - PAT with Contents (r/w). The PAT owner must be a bypass -# actor on the target repository's branch protection ruleset -# so that the direct push to the default branch is allowed. -# SOURCE_REPO - source repository in owner/repo format (e.g. Cratis/AI) -# TARGET_REPO - target repository name (e.g. Chronicle) -# COPILOT_SOURCE_FILES_PATH - optional prepared artifact directory containing -# copilot-files.json and blobs/*.b64 - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=github-api-retry.sh -source "${SCRIPT_DIR}/github-api-retry.sh" - -# Extract a SHA from a gh api JSON response. Returns empty string if: -# - the response is empty -# - the jq path does not exist -# - the value is not a valid 40-char hex SHA -# Usage: sha=$(extract_sha "$response" '.sha') -extract_sha() { - local response="$1" jq_path="${2:-.sha}" - local val - val=$(echo "$response" | jq -r "$jq_path // empty" 2>/dev/null || true) - # Validate: must look like a git SHA (40 or 64 hex chars) - if [[ "$val" =~ ^[0-9a-f]{40,64}$ ]]; then - echo "$val" - fi -} - -source_repo="${SOURCE_REPO:?SOURCE_REPO must be set}" -repo="${TARGET_REPO:?TARGET_REPO must be set}" -source_files_path="${COPILOT_SOURCE_FILES_PATH:-}" -source_blobs_dir="" - -# ---------------------------------------------------------------- -# Fetch or load Copilot files from the source repository -# ---------------------------------------------------------------- -if [ -n "$source_files_path" ]; then - source_files_path="${source_files_path%/}" - source_blobs_dir="${source_files_path}/blobs" - copilot_files_file="${source_files_path}/copilot-files.json" - - echo "Using prepared Copilot source artifact from ${source_files_path}..." - - if [ ! -f "$copilot_files_file" ]; then - echo "::error::Missing prepared Copilot file list: ${copilot_files_file}" - exit 1 - fi - if [ ! -d "$source_blobs_dir" ]; then - echo "::error::Missing prepared Copilot blob directory: ${source_blobs_dir}" - exit 1 - fi - - copilot_files=$(jq -c '.' "$copilot_files_file" 2>/dev/null || true) - if [ -z "$copilot_files" ]; then - echo "::error::Invalid prepared Copilot file list: ${copilot_files_file}" - exit 1 - fi -else - source_files_path=$(mktemp -d) - source_blobs_dir="${source_files_path}/blobs" - - SOURCE_REPO="$source_repo" \ - OUTPUT_DIR="$source_files_path" \ - bash "${SCRIPT_DIR}/prepare-copilot-source-artifact.sh" - - copilot_files_file="${source_files_path}/copilot-files.json" - copilot_files=$(jq -c '.' "$copilot_files_file" 2>/dev/null || true) - if [ -z "$copilot_files" ]; then - echo "::error::Invalid prepared Copilot file list: ${copilot_files_file}" - exit 1 - fi -fi - -if [ -z "$copilot_files" ] || [ "$copilot_files" = "[]" ]; then - echo "No Copilot instruction files found in ${source_repo} — nothing to propagate." - exit 0 -fi -echo "✓ Found $(echo "$copilot_files" | jq 'length') Copilot file(s) in ${source_repo}" - -echo "Processing Cratis/${repo}..." - -# ---------------------------------------------------------------- -# 1. Get default branch and HEAD SHA -# ---------------------------------------------------------------- -repo_info_error=$(mktemp) -default_branch=$(gh_api_with_retry "repos/Cratis/${repo}" \ - --jq '.default_branch' \ - 2>"$repo_info_error" || true) -if [ -z "$default_branch" ]; then - repo_info_api_error=$(cat "$repo_info_error" 2>/dev/null || true) - echo "::error::Could not get default branch for ${repo}" - [ -n "$repo_info_api_error" ] && echo " API error: $repo_info_api_error" - rm -f "$repo_info_error" - exit 1 -fi -rm -f "$repo_info_error" - -head_sha_error=$(mktemp) -_head_sha_resp=$(gh_api_with_retry "repos/Cratis/${repo}/git/ref/heads/${default_branch}" \ - 2>"$head_sha_error" || true) -head_sha=$(extract_sha "$_head_sha_resp" '.object.sha') -if [ -z "$head_sha" ]; then - head_sha_api_error=$(cat "$head_sha_error" 2>/dev/null || true) - echo "::error::Could not get HEAD SHA for ${repo} (${default_branch} branch not found)" - [ -n "$head_sha_api_error" ] && echo " API error: $head_sha_api_error" - rm -f "$head_sha_error" - exit 1 -fi -rm -f "$head_sha_error" - -# ---------------------------------------------------------------- -# 2. Get the commit's tree SHA and current full tree -# ---------------------------------------------------------------- -tree_sha_error=$(mktemp) -_tree_sha_resp=$(gh_api_with_retry "repos/Cratis/${repo}/git/commits/${head_sha}" \ - 2>"$tree_sha_error" || true) -tree_sha=$(extract_sha "$_tree_sha_resp" '.tree.sha') -if [ -z "$tree_sha" ]; then - tree_sha_api_error=$(cat "$tree_sha_error" 2>/dev/null || true) - echo "::error::Could not get tree SHA for ${repo}" - [ -n "$tree_sha_api_error" ] && echo " API error: $tree_sha_api_error" - rm -f "$tree_sha_error" - exit 1 -fi -rm -f "$tree_sha_error" - -subtree_error=$(mktemp) -subtree=$(gh_api_with_retry "repos/Cratis/${repo}/git/trees/${tree_sha}?recursive=1" \ - 2>"$subtree_error" || true) -if [ -z "$subtree" ]; then - subtree_api_error=$(cat "$subtree_error" 2>/dev/null || true) - echo "::error::Could not get tree for ${repo}" - [ -n "$subtree_api_error" ] && echo " API error: $subtree_api_error" - rm -f "$subtree_error" - exit 1 -fi -rm -f "$subtree_error" - -# ---------------------------------------------------------------- -# 3. Check whether all copilot files are already up to date -# (git blob SHAs are content-addressed across repositories) -# ---------------------------------------------------------------- -files_up_to_date=true -while IFS=$'\t' read -r chk_path chk_sha chk_mode; do - [ -z "$chk_path" ] && continue - existing_sha=$(echo "$subtree" | jq -r \ - --arg p "$chk_path" \ - '.tree[] | select(.path == $p) | .sha // empty' 2>/dev/null || true) - existing_mode=$(echo "$subtree" | jq -r \ - --arg p "$chk_path" \ - '.tree[] | select(.path == $p) | .mode // empty' 2>/dev/null || true) - if [ "$existing_sha" != "$chk_sha" ] || [ "$existing_mode" != "$chk_mode" ]; then - files_up_to_date=false - break - fi -done <<< "$(echo "$copilot_files" | jq -r '.[] | .path + "\t" + .sha + "\t" + (.mode // "100644")' 2>/dev/null || true)" - -if [ "$files_up_to_date" = "true" ]; then - echo "ℹ No changes needed for ${repo} (files already up to date)" - exit 0 -fi - -# ---------------------------------------------------------------- -# 4. Create blobs in the target repository for each source file -# ---------------------------------------------------------------- -new_tree_json=$(jq -n --arg base_tree "$tree_sha" \ - '{"base_tree": $base_tree, "tree": []}') - -while IFS=$'\t' read -r src_path src_sha src_mode; do - [ -z "$src_path" ] && continue - - if [ -n "$source_blobs_dir" ]; then - blob_file="${source_blobs_dir}/${src_sha}.b64" - if [ ! -f "$blob_file" ]; then - echo "::error::Prepared source artifact is missing blob for ${src_path} (${src_sha})" - exit 1 - fi - clean_b64=$(tr -d '\n' < "$blob_file") - else - # Fetch blob content from source repo (returned as base64 by API). - # NOTE: zero-byte files return {"content":"","encoding":"base64"} — the - # content field is legitimately empty. We must check whether the API call - # itself succeeded (non-empty JSON response), not whether content is empty. - blob_error=$(mktemp) - blob_resp=$(gh_api_with_retry "repos/${source_repo}/git/blobs/${src_sha}" \ - 2>"$blob_error" || true) - blob_api_error=$(cat "$blob_error" 2>/dev/null || true) - rm -f "$blob_error" - - if [ -z "$blob_resp" ]; then - echo "::error::Could not fetch blob for ${src_path} from ${source_repo}" - [ -n "$blob_api_error" ] && echo " API error: $blob_api_error" - exit 1 - fi - - # Extract content; empty string is valid for zero-byte files - blob_content=$(echo "$blob_resp" | jq -r '.content' 2>/dev/null || true) - - # Strip embedded newlines that the API inserts into base64 output - clean_b64=$(echo "$blob_content" | tr -d '\n') - fi - - target_blob_error=$(mktemp) - _target_blob_resp=$(jq -n \ - --arg content "$clean_b64" \ - '{"content": $content, "encoding": "base64"}' | \ - gh_api_with_retry -X POST "repos/Cratis/${repo}/git/blobs" \ - --input - \ - 2>"$target_blob_error" || true) - target_blob_api_error=$(cat "$target_blob_error" 2>/dev/null || true) - rm -f "$target_blob_error" - target_blob_sha=$(extract_sha "$_target_blob_resp") - - if [ -z "$target_blob_sha" ]; then - echo "::error::Could not create blob for ${src_path} in ${repo}" - [ -n "$target_blob_api_error" ] && echo " API error: $target_blob_api_error" - exit 1 - fi - - new_tree_json=$(echo "$new_tree_json" | jq \ - --arg p "$src_path" \ - --arg s "$target_blob_sha" \ - --arg m "$src_mode" \ - '.tree += [{path: $p, mode: $m, type: "blob", sha: $s}]') -done <<< "$(echo "$copilot_files" | jq -r '.[] | .path + "\t" + .sha + "\t" + (.mode // "100644")' 2>/dev/null || true)" - -# ---------------------------------------------------------------- -# 5. Create new tree and commit -# ---------------------------------------------------------------- -new_tree_error=$(mktemp) -_new_tree_resp=$(echo "$new_tree_json" | \ - gh_api_with_retry -X POST "repos/Cratis/${repo}/git/trees" \ - --input - 2>"$new_tree_error" || true) -new_tree_sha=$(extract_sha "$_new_tree_resp") - -if [ -z "$new_tree_sha" ]; then - new_tree_api_error=$(cat "$new_tree_error" 2>/dev/null || true) - echo "::error::Could not create tree for ${repo}" - [ -n "$new_tree_api_error" ] && echo " API error: $new_tree_api_error" - rm -f "$new_tree_error" - exit 1 -fi -rm -f "$new_tree_error" - -commit_error=$(mktemp) -_commit_resp=$(jq -n \ - --arg msg "Sync Copilot instructions from ${source_repo}" \ - --arg tree "$new_tree_sha" \ - --arg parent "$head_sha" \ - '{"message": $msg, "tree": $tree, "parents": [$parent]}' | \ - gh_api_with_retry -X POST "repos/Cratis/${repo}/git/commits" \ - --input - 2>"$commit_error" || true) -new_commit_sha=$(extract_sha "$_commit_resp") - -if [ -z "$new_commit_sha" ]; then - commit_api_error=$(cat "$commit_error" 2>/dev/null || true) - echo "::error::Could not create commit for ${repo}" - [ -n "$commit_api_error" ] && echo " API error: $commit_api_error" - rm -f "$commit_error" - exit 1 -fi -rm -f "$commit_error" - -echo "✓ Created commit ${new_commit_sha} in ${repo}" - -# ---------------------------------------------------------------- -# 6. Push commit directly to the default branch -# -# A fast-forward (non-force) PATCH updates the ref only if the new -# commit is a descendant of the current HEAD — safe against races. -# The PAT owner must be configured as a bypass actor on the target -# repository's branch protection ruleset for this push to succeed. -# ---------------------------------------------------------------- -push_error=$(mktemp) -push_result=$(gh_api_with_retry -X PATCH "repos/Cratis/${repo}/git/refs/heads/${default_branch}" \ - -f sha="$new_commit_sha" \ - -F force=false \ - 2>"$push_error" || true) -updated_sha=$(extract_sha "$push_result" '.object.sha') - -if [ -z "$updated_sha" ]; then - push_api_error=$(cat "$push_error" 2>/dev/null || true) - push_msg=$(echo "$push_result" | jq -r '.message // empty' 2>/dev/null || true) - echo "::error::Could not push commit to ${default_branch} in ${repo}" - [ -n "$push_api_error" ] && echo " API error: $push_api_error" - [ -n "$push_msg" ] && echo " GitHub message: $push_msg" - rm -f "$push_error" - exit 1 -fi -rm -f "$push_error" - -echo "✓ Pushed Copilot instructions directly to ${default_branch} in ${repo}" diff --git a/.github/scripts/sync-copilot-instructions.sh b/.github/scripts/sync-copilot-instructions.sh deleted file mode 100755 index 944f681..0000000 --- a/.github/scripts/sync-copilot-instructions.sh +++ /dev/null @@ -1,416 +0,0 @@ -#!/usr/bin/env bash -# Synchronizes Copilot instruction files from a source repository to a single -# target repository, opening a PR with the changes. -# Called by .github/workflows/sync-copilot-instructions.yml -# -# Expects: -# GH_TOKEN - PAT with Contents (r/w) + Pull requests (r/w) + Workflows (r/w) -# SOURCE_REPO - source repository in owner/repo format (e.g. Cratis/AI) -# TARGET_REPO - target repository in owner/repo format (e.g. Cratis/SomeRepo) - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Extract a SHA from a gh api JSON response. Returns empty string if: -# - the response is empty -# - the jq path does not exist -# - the value is not a valid 40-char hex SHA -# Usage: sha=$(extract_sha "$response" '.sha') -extract_sha() { - local response="$1" jq_path="${2:-.sha}" - local val - val=$(echo "$response" | jq -r "$jq_path // empty" 2>/dev/null || true) - # Validate: must look like a git SHA (40 or 64 hex chars) - if [[ "$val" =~ ^[0-9a-f]{40,64}$ ]]; then - echo "$val" - fi -} - -source_repo="${SOURCE_REPO:?SOURCE_REPO must be set}" -target_repo="${TARGET_REPO:?TARGET_REPO must be set}" -target_name="${target_repo##*/}" - -branch="copilot-sync/update-instructions" - -pr_body="Synchronizes Copilot instruction files from [${source_repo}](https://github.com/${source_repo}). - -### Changes include: -- Updated \`.github/copilot-instructions.md\` (if present in source) -- Updated \`.github/instructions/\` folder (if present in source) -- Updated \`.github/agents/\` folder (if present in source) -- Updated \`.github/skills/\` folder (if present in source) -- Updated \`.github/prompts/\` folder (if present in source) -- Updated \`.github/hooks/\` folder (if present in source) - -**Source repository:** ${source_repo}" - -echo "Syncing Copilot instructions from ${source_repo} to ${target_repo}..." - -# ---------------------------------------------------------------- -# 1. Prepare Copilot source files -# ---------------------------------------------------------------- -source_files_path=$(mktemp -d) -source_blobs_dir="${source_files_path}/blobs" - -SOURCE_REPO="$source_repo" \ - OUTPUT_DIR="$source_files_path" \ - bash "${SCRIPT_DIR}/prepare-copilot-source-artifact.sh" - -copilot_files_file="${source_files_path}/copilot-files.json" -copilot_files=$(jq -c '.' "$copilot_files_file" 2>/dev/null || true) - -if [ -z "$copilot_files" ] || [ "$copilot_files" = "[]" ]; then - echo "No Copilot instruction files found in ${source_repo} — nothing to sync." - exit 0 -fi - -echo "✓ Found $(echo "$copilot_files" | jq 'length') Copilot file(s) in ${source_repo}" - -# ---------------------------------------------------------------- -# 2. Get target repository info (default branch, node ID, HEAD SHA) -# ---------------------------------------------------------------- -repo_info_error=$(mktemp) -repo_info_json=$(gh api "repos/${target_repo}" \ - --jq '{default_branch: .default_branch, node_id: .node_id}' \ - 2>"$repo_info_error" || true) -default_branch=$(echo "$repo_info_json" | jq -r '.default_branch // empty' 2>/dev/null || true) -repo_node_id=$(echo "$repo_info_json" | jq -r '.node_id // empty' 2>/dev/null || true) -rm -f "$repo_info_error" - -if [ -z "$default_branch" ]; then - echo "::error::Could not get default branch for ${target_repo}" - exit 1 -fi - -head_sha_error=$(mktemp) -_head_sha_resp=$(gh api "repos/${target_repo}/git/ref/heads/${default_branch}" \ - 2>"$head_sha_error" || true) -head_sha=$(extract_sha "$_head_sha_resp" '.object.sha') -rm -f "$head_sha_error" - -if [ -z "$head_sha" ]; then - echo "::error::Could not get HEAD SHA for ${target_repo} (${default_branch} branch not found)" - exit 1 -fi - -# ---------------------------------------------------------------- -# 3. Get the HEAD commit's tree SHA and current full tree -# ---------------------------------------------------------------- -tree_sha_error=$(mktemp) -_tree_sha_resp=$(gh api "repos/${target_repo}/git/commits/${head_sha}" \ - 2>"$tree_sha_error" || true) -tree_sha=$(extract_sha "$_tree_sha_resp" '.tree.sha') -rm -f "$tree_sha_error" - -if [ -z "$tree_sha" ]; then - echo "::error::Could not get tree SHA for ${target_repo}" - exit 1 -fi - -subtree_error=$(mktemp) -subtree=$(gh api "repos/${target_repo}/git/trees/${tree_sha}?recursive=1" \ - 2>"$subtree_error" || true) -rm -f "$subtree_error" - -if [ -z "$subtree" ]; then - echo "::error::Could not fetch repository tree for ${target_repo}" - exit 1 -fi - -# ---------------------------------------------------------------- -# 4. Check for an existing sync branch and open PR early. -# This determines the correct comparison baseline for the -# "up to date" check below: when a PR is already open we compare -# the source against the sync branch tree so that new source -# changes are always committed to the existing PR rather than -# being silently skipped because the default branch looks current. -# ---------------------------------------------------------------- -existing_ref_result=$(gh api graphql \ - -f query='query($owner:String!,$name:String!,$ref:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$ref){id target{oid}}}}' \ - -f owner="Cratis" \ - -f name="$target_name" \ - -f ref="refs/heads/${branch}" \ - 2>/dev/null || true) -existing_ref_id=$(echo "$existing_ref_result" | jq -r '.data.repository.ref.id // empty' 2>/dev/null || true) -existing_branch_sha=$(echo "$existing_ref_result" | jq -r '.data.repository.ref.target.oid // empty' 2>/dev/null || true) - -existing_pr="" -list_pr_error=$(mktemp) -if api_result=$(gh api "repos/${target_repo}/pulls?state=open&head=Cratis:${branch}" 2>"$list_pr_error"); then - existing_pr=$(echo "$api_result" | jq -r '.[0].number // empty' 2>/dev/null || true) -fi -rm -f "$list_pr_error" - -# When a PR is open, compare the source against the sync branch tree -# so we only skip if the PR branch already carries the latest changes. -# When there is no PR, fall back to comparing against the default branch. -comparison_subtree="$subtree" -if [ -n "$existing_pr" ] && [ "$existing_pr" != "null" ] && \ - [ -n "$existing_branch_sha" ] && [ "$existing_branch_sha" != "null" ]; then - echo " ℹ Open PR #${existing_pr} found for ${target_repo} — comparing source against sync branch" - sync_tree_sha_error=$(mktemp) - _sync_tree_sha_resp=$(gh api "repos/${target_repo}/git/commits/${existing_branch_sha}" \ - 2>"$sync_tree_sha_error" || true) - sync_tree_sha=$(extract_sha "$_sync_tree_sha_resp" '.tree.sha') - rm -f "$sync_tree_sha_error" - if [ -n "$sync_tree_sha" ]; then - sync_subtree_error=$(mktemp) - sync_subtree=$(gh api "repos/${target_repo}/git/trees/${sync_tree_sha}?recursive=1" \ - 2>"$sync_subtree_error" || true) - rm -f "$sync_subtree_error" - [ -n "$sync_subtree" ] && comparison_subtree="$sync_subtree" - fi -fi - -# ---------------------------------------------------------------- -# 5. Check whether all copilot files are already up to date -# (git blob SHAs are content-addressed across repositories) -# ---------------------------------------------------------------- -files_up_to_date=true -while IFS=$'\t' read -r chk_path chk_sha chk_mode; do - [ -z "$chk_path" ] && continue - existing_sha=$(echo "$comparison_subtree" | jq -r \ - --arg p "$chk_path" \ - '.tree[] | select(.path == $p) | .sha // empty' 2>/dev/null || true) - existing_mode=$(echo "$comparison_subtree" | jq -r \ - --arg p "$chk_path" \ - '.tree[] | select(.path == $p) | .mode // empty' 2>/dev/null || true) - if [ "$existing_sha" != "$chk_sha" ] || [ "$existing_mode" != "$chk_mode" ]; then - files_up_to_date=false - break - fi -done <<< "$(echo "$copilot_files" | jq -r '.[] | .path + "\t" + .sha + "\t" + (.mode // "100644")' 2>/dev/null || true)" - -if [ "$files_up_to_date" = "true" ]; then - if [ -n "$existing_pr" ] && [ "$existing_pr" != "null" ]; then - echo " ℹ PR #${existing_pr} for ${target_repo} is already up to date with the source" - else - echo "ℹ All Copilot files in ${target_repo} are already up to date — skipping." - fi - exit 0 -fi - -# ---------------------------------------------------------------- -# 6. Create blobs in the target repository for each source file -# ---------------------------------------------------------------- -new_tree_json=$(jq -n --arg base_tree "$tree_sha" \ - '{"base_tree": $base_tree, "tree": []}') - -copy_failed=false -while IFS=$'\t' read -r src_path src_sha src_mode; do - [ -z "$src_path" ] && continue - - blob_file="${source_blobs_dir}/${src_sha}.b64" - if [ ! -f "$blob_file" ]; then - echo " ⚠ Prepared source artifact is missing blob for ${src_path} (${src_sha})" - copy_failed=true - break - fi - - clean_b64=$(tr -d '\n' < "$blob_file") - - target_blob_error=$(mktemp) - _target_blob_resp=$(gh api -X POST "repos/${target_repo}/git/blobs" \ - -f "content=${clean_b64}" \ - -f encoding=base64 \ - 2>"$target_blob_error" || true) - target_blob_sha=$(extract_sha "$_target_blob_resp") - rm -f "$target_blob_error" - - if [ -z "$target_blob_sha" ]; then - echo " ⚠ Could not create blob for ${src_path} in ${target_repo}" - copy_failed=true - break - fi - - new_tree_json=$(echo "$new_tree_json" | jq \ - --arg p "$src_path" \ - --arg s "$target_blob_sha" \ - --arg m "$src_mode" \ - '.tree += [{path: $p, mode: $m, type: "blob", sha: $s}]') -done <<< "$(echo "$copilot_files" | jq -r '.[] | .path + "\t" + .sha + "\t" + (.mode // "100644")' 2>/dev/null || true)" - -if [ "$copy_failed" = "true" ]; then - echo "::error::Failed to prepare file blobs for ${target_repo}" - exit 1 -fi - -# ---------------------------------------------------------------- -# 7. Create new tree, commit -# ---------------------------------------------------------------- -new_tree_error=$(mktemp) -_new_tree_resp=$(echo "$new_tree_json" | \ - gh api -X POST "repos/${target_repo}/git/trees" \ - --input - 2>"$new_tree_error" || true) -new_tree_sha=$(extract_sha "$_new_tree_resp") -rm -f "$new_tree_error" - -if [ -z "$new_tree_sha" ]; then - echo "::error::Could not create tree in ${target_repo}" - exit 1 -fi - -commit_error=$(mktemp) -_commit_resp=$(jq -n \ - --arg msg "Sync Copilot instructions from ${source_repo}" \ - --arg tree "$new_tree_sha" \ - --arg parent "$head_sha" \ - '{"message": $msg, "tree": $tree, "parents": [$parent]}' | \ - gh api -X POST "repos/${target_repo}/git/commits" \ - --input - 2>"$commit_error" || true) -new_commit_sha=$(extract_sha "$_commit_resp") -rm -f "$commit_error" - -if [ -z "$new_commit_sha" ]; then - echo "::error::Could not create commit in ${target_repo}" - exit 1 -fi - -echo " ✓ Created commit ${new_commit_sha} in ${target_repo}" - -# ---------------------------------------------------------------- -# 8. Create or force-update the feature branch via GraphQL -# -# GraphQL createRef/updateRef register the branch in GitHub's branch -# index, which is required for the Pulls API and createPullRequest -# mutation. REST low-level ref writes do NOT register in the index. -# -# existing_ref_id was resolved earlier (before the up-to-date check) -# so we reuse it here instead of issuing a second query. -# ---------------------------------------------------------------- -branch_error=$(mktemp) -branch_ok="" - -if [ -n "$existing_ref_id" ] && [ "$existing_ref_id" != "null" ]; then - branch_result=$(gh api graphql \ - -f query='mutation($refId:ID!,$oid:GitObjectID!){updateRef(input:{refId:$refId,oid:$oid,force:true}){ref{name target{oid}}}}' \ - -f refId="$existing_ref_id" \ - -f oid="$new_commit_sha" \ - 2>"$branch_error" || true) - branch_ok=$(echo "$branch_result" | jq -r '.data.updateRef.ref.name // empty' 2>/dev/null || true) -else - if [ -z "$repo_node_id" ]; then - echo "::error::No repository node ID for ${target_repo}; cannot create branch via GraphQL" - exit 1 - fi - branch_result=$(gh api graphql \ - -f query='mutation($repoId:ID!,$name:String!,$oid:GitObjectID!){createRef(input:{repositoryId:$repoId,name:$name,oid:$oid}){ref{name target{oid}}}}' \ - -f repoId="$repo_node_id" \ - -f name="refs/heads/${branch}" \ - -f oid="$new_commit_sha" \ - 2>"$branch_error" || true) - branch_ok=$(echo "$branch_result" | jq -r '.data.createRef.ref.name // empty' 2>/dev/null || true) -fi - -if [ -z "$branch_ok" ] || [ "$branch_ok" = "null" ]; then - branch_api_error=$(cat "$branch_error" 2>/dev/null || true) - branch_gql_errors=$(echo "$branch_result" | jq -r '(.errors // []) | map(.message) | join("; ")' 2>/dev/null || true) - echo "::error::Could not create/update branch ${branch} in ${target_repo} (GraphQL)" - [ -n "$branch_api_error" ] && echo " stderr: $branch_api_error" - [ -n "$branch_gql_errors" ] && echo " GraphQL errors: $branch_gql_errors" - rm -f "$branch_error" - exit 1 -fi -rm -f "$branch_error" -echo " ✓ Branch ${branch} ready in ${target_repo} (GraphQL)" - -# ---------------------------------------------------------------- -# 9. Create PR, or confirm the existing PR was updated -# ---------------------------------------------------------------- - -# If a PR was already open we have already force-pushed the sync -# branch above, so the PR now reflects the latest changes. -if [ -n "$existing_pr" ] && [ "$existing_pr" != "null" ]; then - echo " ✓ Updated existing PR #${existing_pr} for ${target_repo} with latest Copilot changes" - exit 0 -fi - -pr_created=false - -# Strategy 1: GraphQL createPullRequest mutation -if [ -n "$repo_node_id" ]; then - pr_error=$(mktemp) - gql_input_file=$(mktemp) - jq -n \ - --arg query 'mutation($repoId:ID!,$base:String!,$head:String!,$title:String!,$body:String!){createPullRequest(input:{repositoryId:$repoId,baseRefName:$base,headRefName:$head,title:$title,body:$body}){pullRequest{url}}}' \ - --arg repoId "$repo_node_id" \ - --arg base "$default_branch" \ - --arg head "$branch" \ - --arg title "Sync Copilot Instructions from ${source_repo}" \ - --arg body "$pr_body" \ - '{query:$query,variables:{repoId:$repoId,base:$base,head:$head,title:$title,body:$body}}' \ - > "$gql_input_file" - - pr_response=$(gh api graphql --input "$gql_input_file" 2>"$pr_error" || true) - rm -f "$gql_input_file" - - pr_url=$(echo "$pr_response" | jq -r '.data.createPullRequest.pullRequest.url // empty' 2>/dev/null || true) - if [ -n "$pr_url" ] && [ "$pr_url" != "null" ]; then - echo " ✓ Created PR for ${target_repo} (GraphQL): ${pr_url}" - pr_created=true - else - gql_err=$(cat "$pr_error" 2>/dev/null || true) - gql_errors=$(echo "$pr_response" | jq -r '(.errors // []) | map(.message) | join("; ")' 2>/dev/null || true) - gql_data_errors=$(echo "$pr_response" | jq -r '(.data.createPullRequest.errors // []) | map(.message // .code // "unknown") | join("; ")' 2>/dev/null || true) - echo " ℹ GraphQL PR creation failed for ${target_repo}" - [ -n "$gql_err" ] && echo " stderr: $gql_err" - [ -n "$gql_errors" ] && echo " GraphQL errors: $gql_errors" - [ -n "$gql_data_errors" ] && echo " Mutation errors: $gql_data_errors" - - if echo "$gql_errors$gql_data_errors" | grep -qi "already exists"; then - echo " ℹ PR already exists for ${target_repo} (detected via GraphQL error)" - pr_created=true - fi - fi - rm -f "$pr_error" -fi - -# Strategy 2: REST API fallback -if [ "$pr_created" = "false" ]; then - echo " ℹ Trying REST API fallback for ${target_repo}..." - pr_error=$(mktemp) - rest_input_file=$(mktemp) - - jq -n \ - --arg title "Sync Copilot Instructions from ${source_repo}" \ - --arg body "$pr_body" \ - --arg head "$branch" \ - --arg base "$default_branch" \ - '{title:$title, body:$body, head:$head, base:$base}' \ - > "$rest_input_file" - - pr_response=$(gh api -X POST "repos/${target_repo}/pulls" \ - --input "$rest_input_file" \ - 2>"$pr_error" || true) - rm -f "$rest_input_file" - - pr_url=$(echo "$pr_response" | jq -r '.html_url // empty' 2>/dev/null || true) - - if [ -n "$pr_url" ] && [ "$pr_url" != "null" ]; then - echo " ✓ Created PR for ${target_repo} (REST): ${pr_url}" - pr_created=true - else - rest_err=$(cat "$pr_error" 2>/dev/null || true) - rest_msg=$(echo "$pr_response" | jq -r '.message // empty' 2>/dev/null || true) - rest_errors=$(echo "$pr_response" | jq -r '(.errors // []) | map(.message // .code // "unknown") | join("; ")' 2>/dev/null || true) - echo " ⚠ REST PR creation also failed for ${target_repo}" - [ -n "$rest_err" ] && echo " stderr: $rest_err" - [ -n "$rest_msg" ] && echo " GitHub message: $rest_msg" - [ -n "$rest_errors" ] && echo " Validation errors: $rest_errors" - - if echo "$rest_errors$rest_msg" | grep -qi "already exists"; then - echo " ℹ PR already exists for ${target_repo} (detected via REST 422)" - pr_created=true - elif echo "$rest_errors" | grep -qi "no commits between"; then - echo " ℹ No diff between ${branch} and ${default_branch} for ${target_repo} — skipping" - pr_created=true - fi - fi - rm -f "$pr_error" -fi - -if [ "$pr_created" = "false" ]; then - echo "::error::Could not create PR for ${target_repo}" - exit 1 -fi diff --git a/.github/scripts/tests/ai-corpus-propagation-control.test.sh b/.github/scripts/tests/ai-corpus-propagation-control.test.sh deleted file mode 100755 index fdeea3b..0000000 --- a/.github/scripts/tests/ai-corpus-propagation-control.test.sh +++ /dev/null @@ -1,264 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) Cratis. All rights reserved. -# Licensed under the MIT license. See LICENSE file in the project root for full license information. - -set -euo pipefail - -repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -control_script="$repository_root/.github/scripts/ai-corpus-propagation-control.sh" -test_directory=$(mktemp -d) -mock_bin="$test_directory/bin" -mutation_log="$test_directory/mutations.log" -api_log="$test_directory/api.log" -passed=0 -failed=0 -last_output="" -last_status=0 - -cleanup() { - rm -rf "$test_directory" -} -trap cleanup EXIT - -mkdir -p "$mock_bin" -: > "$mutation_log" -: > "$api_log" - -cat > "$mock_bin/gh" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail - -[ "${1:-}" = "api" ] || { echo "unexpected gh command: $*" >&2; exit 1; } -shift -printf '%q ' "$@" >> "$MOCK_API_LOG" -printf '\n' >> "$MOCK_API_LOG" - -method="GET" -endpoint="" -previous="" -for argument in "$@"; do - if [ "$previous" = "-X" ]; then - method="$argument" - fi - case "$argument" in - orgs/*|repos/*) endpoint="$argument" ;; - esac - previous="$argument" -done - -if [ "${MOCK_FAIL_ENDPOINT:-}" = "$endpoint" ]; then - echo "mock API failure for $endpoint" >&2 - exit 1 -fi - -if [ "$method" != "GET" ]; then - printf '%s %s\n' "$method" "$endpoint" >> "$MOCK_MUTATION_LOG" - if [[ "$endpoint" == */cancel ]]; then - touch "$MOCK_STATE_DIRECTORY/cancelled" - fi - printf '{}\n' - exit 0 -fi - -case "$endpoint" in - "orgs/Cratis/repos?per_page=100&type=all") - cat <<'JSON' -[[{"name":"AI","archived":false},{"name":"Arc","archived":false}],[{"name":"Archived","archived":true},{"name":"Workflows","archived":false}]] -JSON - ;; - "repos/Cratis/AI/actions/workflows?per_page=100") - state="active" - sync_state="active" - if [[ "${MOCK_SCENARIO:-active}" == frozen* ]]; then state="disabled_manually"; sync_state="disabled_manually"; fi - if [[ "${MOCK_SCENARIO:-active}" == *mixed ]]; then sync_state="disabled_inactivity"; fi - printf '[{"workflows":[{"id":101,"name":"Propagate Copilot Instructions","path":".github/workflows/propagate-copilot-instructions.yml","state":"%s"}]},{"workflows":[{"id":102,"name":"Sync Copilot Instructions","path":".github/workflows/sync-copilot-instructions.yml","state":"%s"}]}]\n' "$state" "$sync_state" - ;; - "repos/Cratis/Arc/actions/workflows?per_page=100") - state="active" - if [[ "${MOCK_SCENARIO:-active}" == frozen* ]]; then state="disabled_manually"; fi - printf '[{"workflows":[{"id":201,"name":"Propagate Copilot Instructions","path":".github/workflows/propagate-copilot-instructions.yml","state":"%s"},{"id":202,"name":"Sync Copilot Instructions","path":".github/workflows/sync-copilot-instructions.yml","state":"%s"}]}]\n' "$state" "$state" - ;; - "repos/Cratis/Workflows/actions/workflows?per_page=100") - state="active" - if [[ "${MOCK_SCENARIO:-active}" == frozen* ]]; then state="disabled_manually"; fi - if [ "${MOCK_SCENARIO:-active}" = "missing-central" ]; then - printf '[{"workflows":[{"id":301,"name":"Propagate Copilot Instructions","path":".github/workflows/propagate-copilot-instructions.yml","state":"%s"},{"id":302,"name":"Sync Copilot Instructions","path":".github/workflows/sync-copilot-instructions.yml","state":"%s"}]}]\n' "$state" "$state" - else - printf '[{"workflows":[{"id":301,"name":"Propagate Copilot Instructions","path":".github/workflows/propagate-copilot-instructions.yml","state":"%s"},{"id":302,"name":"Sync Copilot Instructions","path":".github/workflows/sync-copilot-instructions.yml","state":"%s"},{"id":303,"name":"Bootstrap Copilot Sync","path":".github/workflows/bootstrap-copilot-sync.yml","state":"%s"}]}]\n' "$state" "$state" "$state" - fi - ;; - repos/Cratis/*/actions/workflows/*/runs\?per_page=100) - workflow_id=$(printf '%s' "$endpoint" | awk -F/ '{print $(NF-1)}') - if [ "${MOCK_SCENARIO:-active}" = "active" ] && [ "$workflow_id" = "101" ] && [ ! -f "$MOCK_STATE_DIRECTORY/cancelled" ]; then - printf '[{"workflow_runs":[{"id":9001,"status":"in_progress"}]}]\n' - else - printf '[{"workflow_runs":[]}]\n' - fi - ;; - "repos/Cratis/Arc") - printf 'main\n' - ;; - *) - echo "unhandled mock endpoint: $endpoint" >&2 - exit 1 - ;; -esac -MOCK -chmod +x "$mock_bin/gh" - -run_control() { - local scenario="$1" - shift - local output_file="$test_directory/output" - - : > "$mutation_log" - : > "$api_log" - rm -f "$test_directory/cancelled" - set +e - PATH="$mock_bin:$PATH" \ - MOCK_SCENARIO="$scenario" \ - MOCK_MUTATION_LOG="$mutation_log" \ - MOCK_API_LOG="$api_log" \ - MOCK_STATE_DIRECTORY="$test_directory" \ - GH_API_MAX_ATTEMPTS=1 \ - AI_CORPUS_QUIESCENCE_TIMEOUT_SECONDS=2 \ - AI_CORPUS_QUIESCENCE_POLL_SECONDS=1 \ - "$control_script" "$@" > "$output_file" 2>&1 - last_status=$? - set -e - last_output=$(cat "$output_file") -} - -pass() { - echo "ok - $1" - passed=$((passed + 1)) -} - -fail() { - echo "not ok - $1" >&2 - printf '%s\n' "$last_output" | sed 's/^/ /' >&2 - failed=$((failed + 1)) -} - -assert_success() { - local name="$1" - if [ "$last_status" -eq 0 ]; then pass "$name"; else fail "$name"; fi -} - -assert_failure() { - local name="$1" - if [ "$last_status" -ne 0 ]; then pass "$name"; else fail "$name"; fi -} - -assert_contains() { - local name="$1" - local expected="$2" - if grep -Fq "$expected" <<< "$last_output"; then pass "$name"; else fail "$name"; fi -} - -assert_mutation_count() { - local name="$1" - local expected="$2" - local actual - actual=$(wc -l < "$mutation_log" | tr -d ' ') - if [ "$actual" -eq "$expected" ]; then - pass "$name" - else - echo "expected $expected mutations, got $actual" >> "$test_directory/output" - last_output=$(cat "$test_directory/output") - fail "$name" - fi -} - -assert_first_mutation_contains() { - local name="$1" - local expected="$2" - local first_mutation - first_mutation=$(head -n 1 "$mutation_log") - if grep -Fq "$expected" <<< "$first_mutation"; then pass "$name"; else fail "$name"; fi -} - -snapshot="$test_directory/freeze-snapshot.json" - -run_control active freeze --snapshot "$snapshot" -assert_success "freeze dry-run succeeds" -assert_contains "freeze dry-run plans snapshot" "would write the complete pre-mutation snapshot" -assert_mutation_count "freeze dry-run performs no mutations" 0 -if [ ! -e "$snapshot" ]; then pass "freeze dry-run does not write snapshot"; else fail "freeze dry-run does not write snapshot"; fi - -run_control active freeze --apply --snapshot "$snapshot" -assert_failure "applied freeze requires typed organization confirmation" -assert_contains "confirmation failure is explicit" "requires --confirm-organization Cratis" -assert_mutation_count "unconfirmed freeze performs no mutations" 0 - -run_control active freeze --apply --confirm-organization Cratis --snapshot "$snapshot" -assert_success "applied freeze succeeds" -assert_contains "applied freeze verifies quiescence" "no queued or running controlled workflows remain" -assert_mutation_count "applied freeze disables seven workflows and cancels one run" 8 -assert_first_mutation_contains "freeze disables central bootstrap first" "workflows/303/disable" -if jq -e '.schema_version == 1 and (.workflows | length == 7) and all(.workflows[]; .original_state == "active")' "$snapshot" >/dev/null; then - pass "freeze snapshot is complete and versioned" -else - fail "freeze snapshot is complete and versioned" -fi - -run_control active restore --snapshot "$snapshot" --apply --confirm-organization Cratis -assert_failure "restore refuses while a controlled run is active" -assert_contains "restore quiescence refusal is explicit" "refusing restore until the organization is quiescent" -assert_mutation_count "non-quiescent restore performs no mutations" 0 - -run_control frozen restore --snapshot "$snapshot" --apply --confirm-organization Cratis -assert_success "snapshot restore succeeds" -assert_mutation_count "restore enables only seven originally active workflows" 7 - -mixed_snapshot="$test_directory/mixed-snapshot.json" -run_control active-mixed freeze --snapshot "$mixed_snapshot" --apply --confirm-organization Cratis -assert_success "freeze preserves a pre-existing inactive workflow state" -assert_mutation_count "freeze disables only workflows that were active" 6 -if jq -e '[.workflows[] | select(.id == 102 and .original_state == "disabled_inactivity")] | length == 1' "$mixed_snapshot" >/dev/null; then - pass "snapshot records pre-existing inactive state" -else - fail "snapshot records pre-existing inactive state" -fi -run_control frozen-mixed restore --snapshot "$mixed_snapshot" --apply --confirm-organization Cratis -assert_success "restore accepts unchanged pre-existing inactive state" -assert_mutation_count "restore leaves pre-existing inactive workflow untouched" 6 - -run_control frozen verify-frozen -assert_success "verify-frozen accepts a quiescent organization" -assert_contains "verify-frozen reports success" "organization is frozen" - -run_control active verify-frozen -assert_failure "verify-frozen rejects active workflow state" -assert_contains "verify-frozen reports active workflows" "controlled workflow(s) are active" - -run_control active canary --repo Arc -assert_success "canary dry-run preflights complete topology" -assert_contains "canary dry-run plans isolated dispatch" "dispatch Sync Copilot Instructions" -assert_mutation_count "canary dry-run performs no mutations" 0 - -run_control active canary --repo Arc --apply --confirm-organization Cratis -assert_success "applied canary quiesces before dispatch" -assert_mutation_count "canary disables seven, cancels one, enables two, and dispatches once" 11 -assert_first_mutation_contains "canary disables central bootstrap first" "workflows/303/disable" - -run_control missing-central freeze --snapshot "$test_directory/missing.json" -assert_failure "freeze refuses an incomplete central topology" -assert_contains "missing central workflow is reported" "Required workflow is missing" -assert_mutation_count "failed preflight performs no mutations" 0 - -run_control frozen status -assert_success "status succeeds" -assert_contains "status counts all controlled workflows" "Controlled workflows: active=0 disabled_manually=7 other=0" -assert_contains "status reports repositories without omissions" "Queued/running controlled runs: 0" - -stale_snapshot="$test_directory/stale.json" -jq '.workflows |= map(select(.repository != "Arc"))' "$snapshot" > "$stale_snapshot" -run_control frozen restore --snapshot "$stale_snapshot" --apply --confirm-organization Cratis -assert_failure "restore rejects incomplete snapshot coverage" -assert_contains "stale snapshot refusal is explicit" "do not exactly match the current topology" -assert_mutation_count "invalid restore performs no mutations" 0 - -echo -echo "$passed passed, $failed failed" -[ "$failed" -eq 0 ] diff --git a/.github/scripts/tests/github-api-retry.test.sh b/.github/scripts/tests/github-api-retry.test.sh deleted file mode 100755 index 61ba078..0000000 --- a/.github/scripts/tests/github-api-retry.test.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) Cratis. All rights reserved. -# Licensed under the MIT license. See LICENSE file in the project root for full license information. - -set -euo pipefail - -script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -test_directory=$(mktemp -d) -mock_bin="$test_directory/bin" -attempt_file="$test_directory/attempts" -passed=0 -failed=0 - -cleanup() { - rm -rf "$test_directory" -} -trap cleanup EXIT -mkdir -p "$mock_bin" - -cat > "$mock_bin/gh" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail -count=0 -[ ! -f "$MOCK_ATTEMPT_FILE" ] || count=$(cat "$MOCK_ATTEMPT_FILE") -count=$((count + 1)) -printf '%s' "$count" > "$MOCK_ATTEMPT_FILE" -if [ "$count" -lt "${MOCK_SUCCEED_ON:-2}" ]; then - echo "HTTP 503 Service Unavailable" >&2 - exit 1 -fi -echo '{"ok":true}' -MOCK -chmod +x "$mock_bin/gh" - -# shellcheck disable=SC1091 -source "$script_directory/github-api-retry.sh" -sleep() { :; } - -assert_equal() { - local name="$1" - local expected="$2" - local actual="$3" - if [ "$actual" = "$expected" ]; then - echo "ok - $name" - passed=$((passed + 1)) - else - echo "not ok - $name (expected '$expected', got '$actual')" >&2 - failed=$((failed + 1)) - fi -} - -rm -f "$attempt_file" -output=$(PATH="$mock_bin:$PATH" \ - MOCK_ATTEMPT_FILE="$attempt_file" \ - MOCK_SUCCEED_ON=2 \ - GH_API_MAX_ATTEMPTS=3 \ - GH_API_BASE_DELAY_SECONDS=0 \ - GH_API_MAX_JITTER_SECONDS=0 \ - gh_api_with_retry repos/Cratis/AI) -assert_equal "transient 5xx response is retried" "2" "$(cat "$attempt_file")" -assert_equal "successful retry preserves stdout" '{"ok":true}' "$output" - -rm -f "$attempt_file" -set +e -PATH="$mock_bin:$PATH" \ - MOCK_ATTEMPT_FILE="$attempt_file" \ - MOCK_SUCCEED_ON=2 \ - GH_API_MAX_ATTEMPTS=3 \ - GH_API_RETRY_MODE=never \ - gh_api_with_retry -X POST repos/Cratis/Arc/actions/workflows/202/dispatches >/dev/null 2>&1 -status=$? -set -e -assert_equal "retry mode never returns the first failure" "1" "$status" -assert_equal "retry mode never prevents duplicate non-idempotent requests" "1" "$(cat "$attempt_file")" - -echo -echo "$passed passed, $failed failed" -[ "$failed" -eq 0 ] diff --git a/.github/workflows/ai-corpus-propagation-control.yml b/.github/workflows/ai-corpus-propagation-control.yml deleted file mode 100644 index 436d13a..0000000 --- a/.github/workflows/ai-corpus-propagation-control.yml +++ /dev/null @@ -1,158 +0,0 @@ -# Copyright (c) Cratis. All rights reserved. -# Licensed under the MIT license. See LICENSE file in the project root for full license information. - -name: AI Corpus Propagation Control - -on: - workflow_dispatch: - inputs: - operation: - description: Operation to perform - required: true - default: status - type: choice - options: - - status - - verify-frozen - - freeze - - restore - - canary - - enable-single-source - - enable-legacy-all-to-all - canary_repository: - description: Repository name for a canary sync (required for canary) - required: false - type: string - snapshot_run_id: - description: Freeze workflow run ID containing the snapshot artifact (required for restore) - required: false - type: string - apply: - description: Apply GitHub mutations (false performs a dry run) - required: true - default: false - type: boolean - confirm_organization: - description: Type Cratis to confirm an applied operation - required: false - type: string - confirm_legacy_all_to_all: - description: Explicitly confirm the dangerous legacy all-to-all topology - required: true - default: false - type: boolean - -permissions: - contents: read - -concurrency: - group: ai-corpus-propagation-control - cancel-in-progress: false - -jobs: - control: - name: ${{ inputs.operation }} - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 60 - environment: ai-corpus-propagation-control - - steps: - - name: Checkout protected control script - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: refs/heads/main - persist-credentials: false - - - name: Validate operation inputs - env: - OPERATION: ${{ inputs.operation }} - CANARY_REPOSITORY: ${{ inputs.canary_repository }} - SNAPSHOT_RUN_ID: ${{ inputs.snapshot_run_id }} - APPLY: ${{ inputs.apply }} - CONFIRM_ORGANIZATION: ${{ inputs.confirm_organization }} - CONFIRM_LEGACY: ${{ inputs.confirm_legacy_all_to_all }} - run: | - set -euo pipefail - - if [ "$APPLY" = "true" ] && [ "$CONFIRM_ORGANIZATION" != "Cratis" ]; then - echo "::error::Applied operations require confirm_organization to exactly equal Cratis." - exit 2 - fi - if [ "$OPERATION" = "canary" ] && [ -z "$CANARY_REPOSITORY" ]; then - echo "::error::The canary operation requires canary_repository." - exit 2 - fi - if [ "$OPERATION" = "restore" ] && [[ ! "$SNAPSHOT_RUN_ID" =~ ^[0-9]+$ ]]; then - echo "::error::The restore operation requires a numeric snapshot_run_id." - exit 2 - fi - if [ "$OPERATION" = "enable-legacy-all-to-all" ] && [ "$CONFIRM_LEGACY" != "true" ]; then - echo "::error::The legacy topology requires confirm_legacy_all_to_all." - exit 2 - fi - - - name: Download restore snapshot - if: inputs.operation == 'restore' - env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - SNAPSHOT_RUN_ID: ${{ inputs.snapshot_run_id }} - SNAPSHOT_DIRECTORY: ${{ runner.temp }}/ai-corpus-restore - run: | - set -euo pipefail - run_metadata=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SNAPSHOT_RUN_ID") - if ! jq -e ' - .path == ".github/workflows/ai-corpus-propagation-control.yml" and - .head_branch == "main" and - .event == "workflow_dispatch" - ' <<< "$run_metadata" >/dev/null; then - echo "::error::Snapshot run must be a main-branch AI Corpus Propagation Control dispatch." - exit 2 - fi - - mkdir -p "$SNAPSHOT_DIRECTORY" - gh run download "$SNAPSHOT_RUN_ID" \ - --repo "$GITHUB_REPOSITORY" \ - --name ai-corpus-propagation-snapshot \ - --dir "$SNAPSHOT_DIRECTORY" - test -f "$SNAPSHOT_DIRECTORY/ai-corpus-propagation-snapshot.json" - - - name: Control AI corpus propagation - env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - OPERATION: ${{ inputs.operation }} - CANARY_REPOSITORY: ${{ inputs.canary_repository }} - APPLY: ${{ inputs.apply }} - CONFIRM_ORGANIZATION: ${{ inputs.confirm_organization }} - CONFIRM_LEGACY: ${{ inputs.confirm_legacy_all_to_all }} - FREEZE_SNAPSHOT: ${{ runner.temp }}/ai-corpus-propagation-snapshot.json - RESTORE_SNAPSHOT: ${{ runner.temp }}/ai-corpus-restore/ai-corpus-propagation-snapshot.json - run: | - set -euo pipefail - - args=("$OPERATION") - if [ "$OPERATION" = "freeze" ]; then - args+=(--snapshot "$FREEZE_SNAPSHOT") - elif [ "$OPERATION" = "restore" ]; then - args+=(--snapshot "$RESTORE_SNAPSHOT") - fi - if [ -n "$CANARY_REPOSITORY" ]; then - args+=(--repo "$CANARY_REPOSITORY") - fi - if [ "$APPLY" = "true" ]; then - args+=(--apply --confirm-organization "$CONFIRM_ORGANIZATION") - fi - if [ "$CONFIRM_LEGACY" = "true" ]; then - args+=(--confirm-legacy-all-to-all) - fi - - .github/scripts/ai-corpus-propagation-control.sh "${args[@]}" - - - name: Upload pre-freeze state snapshot - if: always() && inputs.operation == 'freeze' && inputs.apply - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: ai-corpus-propagation-snapshot - path: ${{ runner.temp }}/ai-corpus-propagation-snapshot.json - if-no-files-found: error - retention-days: 90 diff --git a/.github/workflows/bootstrap-copilot-sync.yml b/.github/workflows/bootstrap-copilot-sync.yml deleted file mode 100644 index 83dab59..0000000 --- a/.github/workflows/bootstrap-copilot-sync.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Bootstrap Copilot Sync -# Ensures all Cratis repositories have the Copilot sync wrapper workflows -# installed and up-to-date, and that the initial Copilot setup from Cratis/AI -# is in place. Handles both initial bootstrap (new repos) and ongoing updates -# (when the wrapper workflow content or Copilot files change): -# -# • New repos: adds the two wrapper workflows and copies the -# initial Copilot setup from Cratis/AI. -# • Already-bootstrapped: updates wrapper workflows and Copilot files if -# the content has changed since last run. -# • Already up-to-date: skips processing. -# -# This workflow is intentionally manual. It performs org-wide writes and can -# consume a large GitHub REST API budget, so it should not run as a merge check. -# -# Requires PAT_WORKFLOWS secret with permissions: -# Classic PAT: repo + workflow scopes -# Fine-grained PAT: Contents (read/write) + Workflows (read/write) -# -# NOTE: This workflow uses the GitHub Git Data API (blobs/trees/commits/refs) -# instead of git push. GitHub still enforces workflow-file protection -# on POST /git/trees when the tree contains .github/workflows/ paths, so the -# PAT must include the Workflows permission. -# -# The PAT owner must also be configured as a bypass actor on each target -# repository's branch protection ruleset so that direct pushes to the -# default branch are permitted. -# -# Re-running this workflow is safe: it skips repos where content is already -# up-to-date and pushes directly to the default branch for repos that need -# changes. - -on: - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: bootstrap-copilot-sync - cancel-in-progress: false - -jobs: - bootstrap: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Verify PAT can authenticate - env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - run: | - auth_error=$(mktemp) - if ! gh api /user >/dev/null 2>"$auth_error"; then - echo "::error::Could not authenticate with PAT_WORKFLOWS. Is the secret set correctly?" - echo "::error::API error: $(cat "$auth_error")" - rm -f "$auth_error" - exit 1 - fi - rm -f "$auth_error" - login=$(gh api /user --jq '.login') - echo "✓ Authenticated as $login" - # Show which credential gh is actually using — confirms PAT is active - # and not accidentally overridden by GITHUB_TOKEN. - echo " gh auth status:" - gh auth status 2>&1 | sed 's/^/ /' - # Show PAT type and scopes to help diagnose permission issues. - # Classic PATs expose granted scopes via the X-OAuth-Scopes response header. - # Fine-grained PATs do not include this header. - oauth_scopes=$(gh api /user -i 2>/dev/null | grep -i '^x-oauth-scopes:' | sed 's/[^:]*: //' | tr -d '\r' || true) - if [ -n "$oauth_scopes" ]; then - echo " PAT type: classic" - echo " OAuth scopes: $oauth_scopes" - else - echo " PAT type: fine-grained (scopes not exposed via API)" - fi - - - name: Get all Cratis repositories - env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - run: | - # List all non-archived repos (limit 1000 covers foreseeable growth) - # Write to a workspace file to avoid the 21000-character expression - # length limit that applies when passing large values via step outputs. - gh repo list Cratis --limit 1000 --json name,isArchived \ - --jq '[.[] | select(.isArchived == false) | .name]' \ - > "$GITHUB_WORKSPACE/repos.json" - - - name: Bootstrap Copilot sync in all repositories - env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - REPOS_FILE: ${{ github.workspace }}/repos.json - run: | - bash .github/scripts/bootstrap-copilot-sync.sh diff --git a/.github/workflows/cleanup-copilot-sync-branches.yml b/.github/workflows/cleanup-copilot-sync-branches.yml deleted file mode 100644 index 7a48373..0000000 --- a/.github/workflows/cleanup-copilot-sync-branches.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Cleanup Copilot Sync Branches -# Deletes the orphan `add-copilot-sync-workflows` branch from every Cratis -# repository where it still exists (and where no open PR references it). -# -# Run this workflow manually whenever you need to clean up stale bootstrap -# branches left behind by a failed or partial run of bootstrap-copilot-sync. -# -# Requires PAT_WORKFLOWS secret with permissions: -# Classic PAT: repo scope -# Fine-grained PAT: Contents (read/write) - -on: - workflow_dispatch: - inputs: - branch: - description: 'Branch name to delete (default: add-copilot-sync-workflows)' - required: false - default: 'add-copilot-sync-workflows' - type: string - -permissions: - contents: read - -jobs: - cleanup: - runs-on: ubuntu-latest - - steps: - - name: Verify PAT can authenticate - env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - run: | - if ! gh api /user >/dev/null 2>&1; then - echo "::error::Could not authenticate with PAT_WORKFLOWS. Is the secret set correctly?" - exit 1 - fi - login=$(gh api /user --jq '.login') - echo "✓ Authenticated as $login" - - - name: Get all Cratis repositories - id: get-repos - env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - run: | - repos=$(gh repo list Cratis --limit 1000 --json name,isArchived \ - --jq '[.[] | select(.isArchived == false) | .name]') - echo "repos=$repos" >> $GITHUB_OUTPUT - - - name: Delete branch from all repositories - env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - run: | - repos='${{ steps.get-repos.outputs.repos }}' - branch="${{ inputs.branch }}" - deleted_file=$(mktemp) - skipped_file=$(mktemp) - failures_file=$(mktemp) - - echo "$repos" | jq -r '.[]' | while read -r repo; do - echo "Checking Cratis/$repo..." - - # Check whether the branch exists. - # gh api writes the error response body to stdout on 4xx errors; the - # --jq filter '.object.sha' applied to a non-branch response (e.g. - # {"message":"Not Found",...}) yields the literal string "null", so we - # must treat both an empty value and "null" as "branch not found". - branch_sha=$(gh api "repos/Cratis/$repo/git/ref/heads/$branch" \ - --jq '.object.sha' 2>/dev/null || true) - - if [ -z "$branch_sha" ] || [ "$branch_sha" = "null" ]; then - echo " ℹ Branch not found in $repo, skipping" - continue - fi - - # Check whether an open PR references this branch (skip if so). - # Pipe through a separate jq invocation so that error responses (e.g. - # 403 "Resource not accessible by personal access token") are always - # parsed as JSON and never leaked as a raw string into $open_pr. - pr_response=$(gh api "repos/Cratis/$repo/pulls?state=open&head=Cratis:$branch" \ - 2>/dev/null || true) - open_pr=$(echo "$pr_response" | jq -r '.[0].number // empty' 2>/dev/null || true) - if [ -n "$open_pr" ]; then - echo " ⚠ Open PR #$open_pr references $branch in $repo — skipping (close or merge the PR first)" - echo "$repo" >> "$skipped_file" - continue - fi - - # Delete the branch. - # A 422 ("Reference does not exist") means the branch disappeared between - # the existence check and the delete — treat as already gone (success). - # A 409 ("Git Repository is empty") means there is nothing to delete. - # Both are non-error conditions for the cleanup workflow. - delete_output=$(gh api -X DELETE "repos/Cratis/$repo/git/refs/heads/$branch" \ - 2>&1 || true) - delete_status=$(echo "$delete_output" | jq -r '.status // empty' 2>/dev/null || true) - if [ -z "$delete_output" ] || [ "$delete_status" = "422" ] || [ "$delete_status" = "409" ]; then - echo " ✓ Deleted branch $branch from $repo" - echo "$repo" >> "$deleted_file" - else - echo " ✗ Failed to delete branch $branch from $repo" - echo "$repo" >> "$failures_file" - fi - done - - total_deleted=$(wc -l < "$deleted_file" 2>/dev/null || echo "0") - total_skipped=$(wc -l < "$skipped_file" 2>/dev/null || echo "0") - total_failures=$(wc -l < "$failures_file" 2>/dev/null || echo "0") - rm -f "$deleted_file" "$skipped_file" "$failures_file" - - echo "" - echo "Summary: $total_deleted deleted, $total_skipped skipped (open PR), $total_failures failed" - - if [ "$total_failures" -gt 0 ]; then - echo "::error::Failed to delete branch from $total_failures repo(s). Check the log above." - exit 1 - fi diff --git a/.github/workflows/propagate-copilot-instructions.yml b/.github/workflows/propagate-copilot-instructions.yml deleted file mode 100644 index 2ec6f5e..0000000 --- a/.github/workflows/propagate-copilot-instructions.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Propagate Copilot Instructions - -# Compatibility endpoint for existing callers. Legacy cross-repository broadcast -# is retired: a source-repository push must not overwrite another checkout's -# instructions, private overlays, or generated host adapters. -# Keep the call interface while consuming repositories migrate to reviewed, -# immutable AI distribution. Normal verification workflows remain unchanged. -on: - workflow_call: - inputs: - event_name: - description: 'Legacy caller event; retained for compatibility only.' - required: false - type: string - default: 'push' - secrets: - PAT_WORKFLOWS: - description: 'Legacy credential; no longer required or used.' - required: false - -permissions: - contents: read - -jobs: - retired: - name: Legacy propagation is disabled - runs-on: ubuntu-latest - timeout-minutes: 1 - steps: - - name: Explain retired behavior - run: | - printf '%s\n' \ - 'Legacy cross-repository AI propagation is disabled.' \ - 'No repositories, files, branches, or issues were changed.' \ - 'Use reviewed immutable distribution updates instead.' diff --git a/.github/workflows/propagate-pr-templates.yml b/.github/workflows/propagate-pr-templates.yml index 90c0c80..7d48bc0 100644 --- a/.github/workflows/propagate-pr-templates.yml +++ b/.github/workflows/propagate-pr-templates.yml @@ -29,7 +29,7 @@ on: env: # Repositories to skip when propagating templates. - # Must match the exceptions used in propagate-copilot-instructions.yml. + # Historically matched the retired Copilot-sync exceptions; kept as the template-propagation exceptions. REPOS_TO_IGNORE: '["Workflows","cratis.github.io","StudioIssues","Dockerfiles",".github","StudioIssues","cratis.studio"]' permissions: diff --git a/.github/workflows/sync-copilot-instructions.yml b/.github/workflows/sync-copilot-instructions.yml deleted file mode 100644 index ee6abcc..0000000 --- a/.github/workflows/sync-copilot-instructions.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Sync Copilot Instructions -# Reusable workflow that synchronizes Copilot instructions from a source repository -# into the calling (target) repository by opening a PR with the changes. -# -# Uses the GitHub Git Data API (blobs/trees/commits) and GraphQL mutations to -# create the branch and PR — the same approach used by the Bootstrap workflow. -# No local git checkout or push of the target repository is required. -# -# This workflow requires PAT_WORKFLOWS secret with permissions: -# Classic PAT: repo + workflow scopes -# Fine-grained PAT: Contents (read/write) + Pull requests (read/write) -# + Workflows (read/write) - -on: - workflow_call: - inputs: - source_repository: - description: 'Source repository (owner/repo format)' - required: true - type: string - secrets: - PAT_WORKFLOWS: - required: true - -permissions: - contents: read - -jobs: - sync-instructions: - runs-on: ubuntu-latest - - steps: - - 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: Checkout Workflows repository - uses: actions/checkout@v4 - with: - repository: Cratis/Workflows - ref: main - token: ${{ secrets.PAT_WORKFLOWS }} - - - name: Sync Copilot instructions to current repository - env: - GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} - SOURCE_REPO: ${{ inputs.source_repository }} - TARGET_REPO: ${{ github.repository }} - run: | - bash .github/scripts/sync-copilot-instructions.sh diff --git a/.github/workflows/verify-no-work-records.yml b/.github/workflows/verify-no-work-records.yml index 1c06c66..b806ab9 100644 --- a/.github/workflows/verify-no-work-records.yml +++ b/.github/workflows/verify-no-work-records.yml @@ -2,9 +2,8 @@ name: Verify No Work Records # Reusable guard that fails when AI session work records (plans, handovers, # session notes, continuation prompts, status boards) are tracked in git. -# Per the shared corpus rule (.ai/rules/local-work-artifacts.md), such files -# are local-only and live in the gitignored .ai-work/ folder; durable -# follow-ups become GitHub issues instead of planning files. +# Such files are local-only and live in the gitignored .ai-work/ folder; +# durable follow-ups become GitHub issues instead of planning files. on: workflow_call: @@ -55,7 +54,7 @@ jobs: # adapter folders (uppercase convention only, to spare real docs). while IFS= read -r f; do case "$f" in - .ai/*|.claude/*|.github/*|.pi/*|.agents/*|.ai-work/*) continue ;; + .claude/*|.github/*|.pi/*|.agents/*|.cratis/*|.ai-work/*) continue ;; esac violations+=("session work record: $f") done < <(git ls-files -- '*.md' | grep -E '(^|/)[^/]*HANDOVER[^/]*\.md$|(^|/)PROMPT-[^/]+\.md$|(^|/)[^/]*NEXT-SESSION[^/]*\.md$|(^|/)SESSION-PROMPT[^/]*\.md$|(^|/)[^/]*SESSION[-_]HANDOVER[^/]*\.md$' || true) @@ -64,7 +63,7 @@ jobs: printf '::error::%s\n' "${violations[@]}" echo "" echo "AI session work artifacts are local-only: keep them in the untracked" - echo ".ai-work/ folder (see .ai/rules/local-work-artifacts.md). A follow-up" + echo ".ai-work/ folder. A follow-up" echo "that must survive the session becomes a GitHub issue, not a file." exit 1 fi diff --git a/.github/workflows/verify-release-intent.yml b/.github/workflows/verify-release-intent.yml index 0b9e764..3f55b0b 100644 --- a/.github/workflows/verify-release-intent.yml +++ b/.github/workflows/verify-release-intent.yml @@ -7,7 +7,8 @@ name: Verify Release Intent # # `no-release` is the fourth accepted answer, and it is a decision rather than an # omission: the pull request changes nothing a consumer compiles against or runs, so -# there is nothing to version. See .ai/rules/pull-requests.md in Cratis/AI. +# there is nothing to version. The policy is maintained with the Cratis AI +# engineering guidance (github.com/Cratis/AI). # # This lives here because 30+ per-repository copies of this logic drifted apart, and # on 2026-08-25 the repositories whose copies predated `no-release` forced diff --git a/AGENTS.md b/AGENTS.md index ddf2ec9..52e68fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,6 @@ -# Workflows — Project Instructions +# Agents -## Repository scope and rule routing - -Infrastructure/workflow profile: organization-owned reusable GitHub Actions, not an application. Read [the reviewed AI profile update contract](README.md#reviewed-cratis-ai-profile-updates). No `.ai/` corpus is installed here. Legacy broadcast/bootstrap prose in the README is historical, not authorization to run propagation; shared AI updates use reviewed immutable versions, preserve local overlays, and retain legacy adapters until approved canary retirement. - -Read `.cratis/PROJECT.md` when present; use `.agents/PROJECT.md` only if canonical context is absent. Context may reference approved secret mechanisms, never secret values, and cannot weaken security, authorization, or required gates. Run proportional checks after coherent changes; diagnose unrelated/environmental failures within a bounded attempt and report blockers rather than bypassing required checks. - -## Local AI work artifacts — `.ai-work/` only - -AI-assisted sessions produce working artifacts: plans, handover documents, session notes, continuation prompts, status boards, scratch analyses, research dumps. These are **work records, not documentation**: - -- Create every such artifact inside **`.ai-work/`** at the repository root — never at the repository root itself, never under documentation folders, never anywhere else. -- `.ai-work/` is gitignored and must stay untracked. Never commit anything inside it, never `git add -f` anything inside it, and never remove the ignore entry. -- These artifacts must never enter git history or reach GitHub — not on any branch. If you find an unrelated tracked work record, report its path and obtain explicit authorization before moving it into `.ai-work/`, removing it from tracking, or making a dedicated cleanup commit. Discovery alone does not authorize unrelated changes or a commit. -- A genuine follow-up that must survive the session is **not** a work record — suggest opening a GitHub issue for it (or open one when asked) so future work is tracked where everyone can see it, instead of leaving a planning file behind. -- Knowledge that must outlive the session belongs in the repository's documentation structure through normal review, not in a work record. +Read [`.cratis/PROJECT.md`](.cratis/PROJECT.md) before working in this +repository — it is the canonical project context and the only one: do not +merge it with any other context file. `.cratis/ai.json` records the Cratis +profiles this repository subscribes to. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e97081 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@.cratis/PROJECT.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..6e97081 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +@.cratis/PROJECT.md diff --git a/README.md b/README.md index 3ca288a..94f9a6c 100644 --- a/README.md +++ b/README.md @@ -39,73 +39,26 @@ Add `--apply` only in a disposable/local checkout. The hosted workflow owns branch and PR creation. Its one-time GitHub App installation scope and first canary repositories remain tracked in Cratis/Workflows#72 and #73. -## Getting started with your Cratis repository +## AI setup in Cratis repositories -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 [Bootstrap Copilot Sync](#bootstrap-copilot-syncyml), which installs or refreshes the wrappers and corpus files directly on each repository's default branch. +The legacy Copilot synchronization system — all-to-all propagation, the +per-repository sync wrappers, and the corpus bootstrap — is **retired**. +Cratis repositories no longer carry a synchronized `.ai/` corpus or generated +tool adapters. -If you prefer to add the workflows manually, create the following two files: +Each repository instead commits the small, reviewed AI contract: -**`.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: - - ".ai/**" - - ".claude/**" - - ".agents/**" - - "AGENTS.md" - - ".github/copilot-instructions.md" - - ".github/instructions/**" - - ".github/agents/**" - - ".github/skills" - - ".github/skills/**" - - ".github/prompts" - - ".github/prompts/**" - - ".github/hooks/**" - workflow_dispatch: - -jobs: - propagate: - uses: Cratis/Workflows/.github/workflows/propagate-copilot-instructions.yml@main - with: - event_name: ${{ github.event_name }} - secrets: inherit -``` - -Both wrapper workflows require the `PAT_WORKFLOWS` secret to be set in the repository or inherited from the organization. - -| PAT type | Required permissions | +| File | Role | |---|---| -| Classic PAT | `repo` scope (full repository access) | -| Fine-grained PAT | **Contents** (read/write) + **Metadata** (read) | +| `.cratis/PROJECT.md` | the repository's own project context (facts, commands, conventions) | +| `.cratis/ai.json` | the profile subscription: which `cratis/*` profiles the repository expects | +| `AGENTS.md` + `CLAUDE.md`/`GEMINI.md` | minimal bootstraps pointing at `.cratis/PROJECT.md` | -> [!IMPORTANT] -> The propagation workflow pushes directly to the default branch of each target repository. The GitHub user account that owns `PAT_WORKFLOWS` must therefore be configured as a **bypass actor** on every target repository's branch protection ruleset. See [Branch protection setup](#branch-protection-setup) below for the exact steps. +Shared behavior arrives through the Cratis AI marketplace plugins (see the +[harness guide](https://www.cratis.io/ai/harnesses/)); profile updates land as +reviewed pull requests through `update-ai-profile-subscription.yml` above. +General improvements are proposed in `Cratis/AI`, never synchronized +back from a consuming repository. --- @@ -297,307 +250,8 @@ The common workflow bootstrap also propagates **`.github/codeql/codeql-config.ym --- -## How it works - -### Copilot instruction synchronization - -Copilot and AI-assistant artifacts are broadcast between 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 | -| `.github/skills/` | Folder of skill files | -| `.github/prompts/` | Folder of prompt files | -| `.github/hooks/` | Folder of hook files | -| `.ai/` | AI setup folder (including prompts, skills, agents, hooks, and rules) | -| `.claude/` | Claude setup folder (including symlinks such as `.claude/* -> .ai/*`) | -| `.agents/` | Codex adapter folder (except `.agents/PROJECT.md` — see below) | -| `AGENTS.md` | Root Codex instructions file | - -**`.agents/PROJECT.md` is never synchronized.** It is each repository's *project-local* instruction -file — the one place a repo records what is true only of itself: its endpoints, credentials, -deployment recipes, issue tracker, and any convention that overrides the shared corpus for that repo -alone. Propagating it means whichever repository pushes last overwrites every other repository's -context, which is exactly what happened repeatedly before it was excluded here. The exclusion is -unconditional and lives in the propagation scripts rather than in each repository's -`.copilot-sync-ignore`, because that file only protects the repository it lives in — one repo -without one is enough to clobber all the others. It is also excluded from cleanup, so bootstrap -never deletes it. - -Adapters are normalized and propagated as adapters. When a matching canonical `.ai` file exists in the source tree, known adapter paths are written as symlinks or path-reference files, even if the source repository currently contains copied content at that adapter path. This keeps `.ai/` as the synchronized source of truth instead of duplicating `.ai` content into tool-specific files. - -### Excluding files from synchronization - -If a repository contains Copilot artifacts that are specific to that repository and should **not** be synced to other repos, create a `.github/.copilot-sync-ignore` file in the source repository. It works like a `.gitignore` — list one glob pattern per line. - -```text -# Skills that are specific to this repository -skills/repo-specific-skill.md - -# A whole subfolder of instructions -instructions/local-only/ - -# Wildcard examples -skills/experimental-* -prompts/draft-?.md -``` - -**Rules:** - -| Feature | Syntax | -|---|---| -| Comment | Lines starting with `#` | -| Single-segment wildcard | `*` — matches any characters except `/` | -| Multi-segment wildcard | `**` — matches across directory boundaries | -| Single-character wildcard | `?` — matches exactly one character | -| `.github/` prefix | Optional — `skills/foo.md` and `.github/skills/foo.md` are equivalent | - -When the `.copilot-sync-ignore` file is present in the source repository, any matching Copilot files are excluded before the changes are pushed to the target repository. - -### Propagation flow - -When AI corpus files are pushed to `main` in any Cratis repository: - -```mermaid -sequenceDiagram - participant Source as Source Repo
(e.g. Chronicle or AI) - participant Propagate as propagate-copilot-instructions
(Cratis/Workflows) - participant Target as Target Repo
(e.g. Arc, Fundamentals, …) - - Source->>Propagate: push to main
(AI corpus paths changed) - Propagate->>Propagate: Validate caller is Cratis org - Propagate->>Propagate: List all Cratis repositories - Propagate->>Source: Fetch AI corpus files once
(normalize adapters) - Propagate->>Propagate: Upload source files as workflow artifact - loop For each target repo (except source) - Propagate->>Propagate: Download source artifact - Propagate->>Target: Create blob/tree/commit objects - Propagate->>Target: Push commit directly to main
(commit message: "Sync Copilot instructions from …") - Note over Target: Commit message guard prevents
recursive re-propagation - end -``` - -**Anti-loop guard:** the propagated commit message starts with `Sync Copilot instructions from`, which the propagation workflow detects on the next push event and skips — preventing a recursive trigger chain. - -### Sync workflow detail - -```mermaid -flowchart TD - A([workflow_dispatch\nsource_repository input]) --> B{Validate format\nand Cratis org} - B -- invalid --> Z([Exit with error]) - B -- valid --> C[Fetch source repo tree via API] - C --> D{copilot-instructions.md\nexists in source?} - D -- yes --> E[Copy to target .github/] - D -- no --> F - E --> F{instructions/ folder\nexists in source?} - F -- yes --> G[Replace .github/instructions/] - F -- no --> H - G --> H{agents/ folder\nexists in source?} - H -- yes --> I[Replace .github/agents/] - H -- no --> J - I --> J[Open PR with changes] - J --> K([Done]) -``` - -### Temporarily freezing and restoring propagation - -Use [`.github/scripts/ai-corpus-propagation-control.sh`](.github/scripts/ai-corpus-propagation-control.sh) -to pause the AI corpus distribution system without editing workflow files or -pushing commits. The script requires Bash, `gh`, and `jq`. It reads every active -organization repository and paginates workflow and run results before planning any -mutation. - -All mutating operations dry-run unless both `--apply` and an exact organization -confirmation are supplied. An applied freeze also requires a new snapshot path: - -```bash -# Read-only inspection and a strict frozen-state check. -.github/scripts/ai-corpus-propagation-control.sh status -.github/scripts/ai-corpus-propagation-control.sh verify-frozen - -# Preview a freeze. This does not write the snapshot or call a mutating API. -.github/scripts/ai-corpus-propagation-control.sh freeze \ - --snapshot /secure/path/ai-corpus-snapshot.json - -# Apply a freeze. The snapshot is written before the first API mutation. -.github/scripts/ai-corpus-propagation-control.sh freeze \ - --snapshot /secure/path/ai-corpus-snapshot.json \ - --apply --confirm-organization Cratis - -# Restore exactly the workflows recorded active before that freeze. -.github/scripts/ai-corpus-propagation-control.sh restore \ - --snapshot /secure/path/ai-corpus-snapshot.json -.github/scripts/ai-corpus-propagation-control.sh restore \ - --snapshot /secure/path/ai-corpus-snapshot.json \ - --apply --confirm-organization Cratis -``` - -Snapshots are versioned JSON inventories containing repository coverage, workflow -paths and IDs, and original states. Restore refuses malformed snapshots, a different -organization, repository/workflow topology drift, incompatible state drift, or -any queued/running controlled run. It enables only entries recorded as `active`; -workflows already inactive before the freeze remain untouched. Keep snapshots -access-controlled even though they contain no token. - -Freeze disables central bootstrap first, then disables only currently active -controlled workflows, cancels queued/running bootstrap, propagation, and sync runs, -and polls until no controlled run remains. Cancellation is irreversible: restore -does not restart canceled runs, revert corpus files, or roll back a partially -completed propagation. - -For a deliberate topology change, first preview and then apply a canary or the -single-source model: - -```bash -# Enable only central sync plus one target sync wrapper, then dispatch one PR sync. -.github/scripts/ai-corpus-propagation-control.sh canary --repo Arc -.github/scripts/ai-corpus-propagation-control.sh canary --repo Arc \ - --apply --confirm-organization Cratis - -# Enable manual sync wrappers and automatic propagation only from Cratis/AI. -.github/scripts/ai-corpus-propagation-control.sh enable-single-source -.github/scripts/ai-corpus-propagation-control.sh enable-single-source \ - --apply --confirm-organization Cratis -``` - -Both operations first disable and quiesce the complete controlled topology. The -legacy all-to-all topology is available only with two explicit confirmations: - -```bash -.github/scripts/ai-corpus-propagation-control.sh enable-legacy-all-to-all \ - --confirm-legacy-all-to-all \ - --apply --confirm-organization Cratis -``` - -Prefer `canary` followed by `enable-single-source`; all-to-all allows a corpus -change in any repository to initiate organization-wide fan-out. - -The same operations are available from the **AI Corpus Propagation Control** -manual workflow. `apply` defaults to `false`. Applied freezes upload a 90-day -`ai-corpus-propagation-snapshot` artifact; restore requires the freeze workflow run -ID and downloads that exact artifact. The current organization freeze predates this -snapshot mechanism, so it cannot be exactly reversed with `restore`; resume it only -through a reviewed canary/single-source plan or a separately verified snapshot. - -The workflow executes only from `main`, checks out the protected `main` script, and -uses the `ai-corpus-propagation-control` GitHub Environment. Configure that -environment with required reviewers and a deployment-branch rule allowing only -`main` before adding `PAT_WORKFLOWS`. This environment protection is the security -boundary that prevents a branch-selected workflow from exposing the organization -PAT. - -The token needs organization repository visibility and **Actions: read** for -`status`, `verify-frozen`, and dry-runs. Applied state changes and run cancellation -need **Actions: read/write** for every controlled repository. Canary and propagation -also need the Contents/Pull requests/Workflows permissions documented for their -respective reusable workflows. - -Disabling a workflow through the Actions API persists across pushes until it is -explicitly enabled again. Existing corpus files remain in each repository; a -freeze neither reverts nor deletes already-synchronized content. - -Run the offline validation suite without a GitHub token: - -```bash -bash -n .github/scripts/*.sh .github/scripts/tests/*.sh -shellcheck -x .github/scripts/ai-corpus-propagation-control.sh \ - .github/scripts/github-api-retry.sh .github/scripts/tests/*.sh -actionlint .github/workflows/ai-corpus-propagation-control.yml -bash .github/scripts/tests/github-api-retry.test.sh -bash .github/scripts/tests/ai-corpus-propagation-control.test.sh -``` - ---- - ## Workflows in this repository -### `ai-corpus-propagation-control.yml` - -**Trigger:** `workflow_dispatch` - -Runs the status/verify/freeze/snapshot-restore/canary/topology script through a -guarded manual interface. Mutations require `apply` plus an exact `Cratis` -confirmation. Restoring a snapshot requires the freeze workflow run ID. Restoring -the legacy all-to-all topology additionally requires -`confirm_legacy_all_to_all`. - -**Environment required:** protected `ai-corpus-propagation-control`, restricted to -`main` and configured with required reviewers. - -**Secrets required:** `PAT_WORKFLOWS` with Actions read/write access in every -controlled repository and the extra permissions required by the selected -synchronization mode. - ---- - -### `sync-copilot-instructions.yml` - -**Trigger:** `workflow_call` (invoked by each target repository) - -Fetches the Copilot artifacts from the `source_repository` via the GitHub API 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_WORKFLOWS` — classic PAT with `repo` scope, or fine-grained PAT with **Contents** + **Pull requests** read/write + **Metadata** read - ---- - -### `propagate-copilot-instructions.yml` - -**Trigger:** `workflow_call` (invoked by the source repository on push to `main`) - -Lists all repositories in the Cratis organization and pushes the Copilot instruction files directly to the default branch of each one (except the caller). The source files are fetched once and reused from a workflow artifact while target repositories are processed with bounded parallelism. Silently skips repositories where files are already up to date. - -**Anti-loop protection:** commits made by this workflow start with `Sync Copilot instructions from`, which the workflow detects on subsequent pushes and skips — preventing recursive propagation chains. - -**Validation:** Exits early if the calling repository does not belong to the `Cratis` organization. - -**Secrets required:** `PAT_WORKFLOWS` — classic PAT with `repo` scope, or fine-grained PAT with **Contents** read/write + **Metadata** read. The PAT owner must be a bypass actor on each target repository's branch protection ruleset. - ---- - -### `bootstrap-copilot-sync.yml` - -**Trigger:** `workflow_dispatch` (manual bootstrap and refresh) - -This workflow is intentionally manual because it performs organization-wide writes and can consume a large GitHub REST API budget. Use it for planned bootstrap, wrapper rollout, or managed corpus refresh work, not as a merge-triggered check. - -For every non-archived repository in the Cratis organization except `Workflows`, it: - -1. Creates or updates the two thin wrapper workflows shown in [Getting started](#getting-started-with-your-cratis-repository). -2. Refreshes the managed Copilot corpus files from `Cratis/AI` and removes obsolete managed files. -3. Creates a non-force, fast-forward commit directly on the repository's default branch when content changed. -4. Skips repositories that are already up to date. - -**Secrets required:** `PAT_WORKFLOWS` — classic PAT with `repo` + `workflow` scopes, or fine-grained PAT with **Contents** + **Workflows** read/write. The PAT owner must be a bypass actor for each target's default-branch protection. - ---- - -### `update-synced-workflows.yml` - -**Trigger:** `workflow_dispatch` (run manually when wrapper workflow templates change) - -Propagates the latest wrapper workflow files to all Cratis repositories. Run this workflow whenever the installed wrapper templates in this repository change (for example, when a new trigger or input is added). - -For each non-archived repository (except `Workflows` itself), it: - -1. Skips repositories where both wrapper files already match the latest version. -2. Skips repositories where neither wrapper file is present (not yet bootstrapped — run `bootstrap-copilot-sync.yml` first). -3. Creates or force-updates a branch `update-synced-workflows` with a commit that updates the two wrapper workflow files. -4. Opens a pull request targeting the repository's default branch. - -**Secrets required:** `PAT_WORKFLOWS` — classic PAT with `repo` + `workflow` scopes, or fine-grained PAT with **Contents** + **Pull requests** + **Workflows** read/write - ---- - ### `cleanup-pr-artifacts.yml` **Triggers:** `workflow_call` for closed-PR callers; `workflow_dispatch` in Workflows @@ -632,77 +286,12 @@ separate approval before merging a watched file. Propagates the Pull Request and Issue templates from this repository (`Cratis/Workflows`) directly to the default branch of every other non-archived Cratis repository. Silently skips repositories where files are already up to date. -**Excluded repositories:** `Workflows`, `cratis.github.io`, `StudioIssues` (same exceptions as `propagate-copilot-instructions.yml`). +**Excluded repositories:** `Workflows`, `cratis.github.io`, `StudioIssues`. **Secrets required:** `PAT_WORKFLOWS` — classic PAT with `repo` scope, or fine-grained PAT with **Contents** read/write + **Metadata** read. The PAT owner must be a bypass actor on each target repository's branch protection ruleset. --- -### `cleanup-copilot-sync-branches.yml` - -**Trigger:** `workflow_dispatch` (run manually when needed) - -Utility workflow that deletes the `add-copilot-sync-workflows` branch (or any branch name you specify via the `branch` input) from every non-archived Cratis repository where it exists. It automatically skips repositories where an open pull request still references the branch. - -Use this workflow to clean up orphan branches left behind by a partial or failed run of `bootstrap-copilot-sync.yml`. - -**Inputs:** - -| Input | Required | Default | Description | -|---|---|---|---| -| `branch` | No | `add-copilot-sync-workflows` | Name of the branch to delete across all repositories | - -**Secrets required:** `PAT_DOCUMENTATION` — classic PAT with `repo` scope, or fine-grained PAT with **Contents** read/write - ---- - -## Branch protection setup - -Because `propagate-copilot-instructions.yml` pushes directly to each target repository's default branch, you must grant the PAT owner permission to bypass the normal branch protection rules. Use GitHub **Repository Rulesets** (not the legacy "Branch protection rules") so that you can add a precise bypass actor. - -### Steps (repeat for every target repository) - -1. Go to **Settings → Rules → Rulesets** in the target repository. -2. Click **New ruleset → New branch ruleset**. -3. Set **Target branches** to the default branch (e.g. `main`). -4. Enable the rule **Require a pull request before merging** (and any other rules you want, such as required status checks). -5. Under **Bypass list**, click **Add bypass** and add the GitHub user account that owns `PAT_WORKFLOWS`. Set the bypass role to **Always**. -6. Save the ruleset. - -> [!TIP] -> If you manage many repositories you can create the ruleset at the **organization level** (Organization Settings → Rules → Rulesets), target all repositories, and add the bypass actor once. - -### Path enforcement - -The bypass actor can technically push any content to the default branch. The workflow enforces the path constraint in code — it only ever commits files under: - -``` -.github/copilot-instructions.md -.github/instructions/ -.github/agents/ -.github/skills/ -.github/prompts/ -.github/hooks/ -.github/ISSUE_TEMPLATE/ -.github/pull_request_template.md -``` - -For an extra layer of defence you can add a **Restrict file paths** rule to the ruleset that blocks direct changes to files *outside* these paths from all other actors. The bypass actor is exempt from this restriction, but since the bypass is scoped to a dedicated service account whose only use is this workflow, the effective risk is minimal. - -### Choosing the PAT owner - -Use a dedicated GitHub service-account (bot user) for `PAT_WORKFLOWS`, not a personal developer account. This makes the bypass list easy to audit and ensures the token is never accidentally shared with workflows that should not have direct-push access. - -### Anti-loop protection - -Every commit created by the propagation workflow uses the message: - -``` -Sync Copilot instructions from / -``` - -The `propagate-copilot-instructions.yml` workflow in each target repository detects this prefix on the next `push` event and exits without triggering another round of propagation, preventing recursive loops. - ## Part of the Cratis ecosystem These workflows power CI/CD across the [Cratis](https://github.com/Cratis) open-source ecosystem — [Chronicle](https://github.com/Cratis/Chronicle) (event sourcing database and runtime), [Arc](https://github.com/Cratis/Arc) (CQRS for ASP.NET Core), [Components](https://github.com/Cratis/Components) (React), the [CLI](https://github.com/Cratis/cli), and more. Documentation lives at [cratis.io](https://www.cratis.io).