diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000..b849c2cdc --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,130 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SOURCE OF TRUTH: petry-projects/.github/standards/workflows/copilot-setup-steps.yml +# Standard: petry-projects/.github/standards/ci-standards.md#11-copilot-cloud-agent-setup +# +# ADOPTING THIS WORKFLOW: +# 1. Copy this file to .github/workflows/copilot-setup-steps.yml in your repo. +# 2. Keep every REQUIRED section — do NOT remove the checkout or verify steps. +# 3. Uncomment and adapt the stack blocks that match your repo's tech stack. +# 4. Delete (not just comment out) stacks that do not apply after initial setup. +# 5. Merge to the default branch — the workflow only triggers from the default branch. +# 6. Run manually from Actions → "Copilot Setup Steps" → "Run workflow" to verify. +# +# WHAT THIS FILE DOES: +# Bootstraps Copilot cloud agent's ephemeral environment BEFORE the agent starts +# working on your repo. Pre-installing dependencies deterministically: +# • Speeds up every agent session (no trial-and-error dependency discovery) +# • Makes private/internal packages available (impossible for the agent alone) +# • Ensures exact tool versions that match your CI pipeline +# Without this file the agent installs dependencies itself — slower, non-deterministic, +# and unreliable for repos with private packages or complex build graphs. +# +# SEE ALSO: +# AGENTS.md — authoritative development standards for this repo +# .github/copilot-instructions.md — always-on Copilot instructions (summary of AGENTS.md) +# .github/instructions/ — path-scoped instruction files per language +# +# CONSTRAINTS (enforced by GitHub, documented at docs.github.com): +# • Job MUST be named `copilot-setup-steps` to be recognized by Copilot cloud agent +# • timeout-minutes maximum: 59 (hard limit) +# • Customizable fields: steps, permissions, runs-on, services, snapshot, timeout-minutes +# • All other job-level settings are silently ignored by GitHub +# • fetch-depth on checkout is always overridden by the agent — do not rely on it here +# • This file MUST be present on the default branch to take effect +# +# AGENTS — READ BEFORE EDITING: +# This file is a PER-REPO TEMPLATE (Tier 2). There is no central reusable workflow +# because setup steps are inherently tech-stack-specific. Each repo owns its copy. +# Stay within the patterns documented in ci-standards.md §11. Do not change: +# • the job name (`copilot-setup-steps`) +# • the fork guard condition +# • the trigger events +# • the verify-environment step +# For changes to the org-wide pattern, open a PR against this file in +# petry-projects/.github and propagate to the fleet. +# ───────────────────────────────────────────────────────────────────────────── + +name: "Copilot Setup Steps" + +# Run automatically when this file changes (validates the setup steps work), +# and allow manual runs from the Actions tab at any time. +# NOT a standard CI trigger — this file should NOT be added to push: branches: [main]. +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +# Defense-in-depth: reset top-level permissions; set exact grants per-job. +# Pattern from envoyproxy/envoy and the org Permissions Policy (ci-standards.md). +permissions: {} + +jobs: + # ── REQUIRED: Job name must be exactly `copilot-setup-steps` ──────────────── + copilot-setup-steps: + # Skip on fork-origin pull requests — forks cannot access org secrets required for private packages. + # `github.event.repository.fork` reflects the base repo (always false for org repos); the + # correct guard is on the PR head repo so that external fork PRs are skipped while direct + # pushes and manual runs always proceed. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + + runs-on: ubuntu-latest + + # Adjust upward only if your build or download steps genuinely need more time. + # Hard maximum enforced by GitHub is 59 minutes. + timeout-minutes: 30 + + # Minimum permissions — Copilot receives its own separate token for its operations. + permissions: + contents: read + + steps: + # ── REQUIRED ────────────────────────────────────────────────────────────── + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # ── REQUIRED ────────────────────────────────────────────────────────────── + # Environment verification — always runs last. Fails loud if something is + # missing so the problem is caught here rather than mid-agent-session. + # Also surfaces the agent instruction files so session logs confirm they + # were found before the agent began working. + - name: Verify environment + run: | + echo "=== petry-projects Copilot cloud agent environment ===" + echo "Repository : ${GITHUB_REPOSITORY}" + echo "Ref : ${GITHUB_REF}" + echo "Runner : ${RUNNER_OS} / ${RUNNER_ARCH}" + echo "" + echo "--- Installed tool versions ---" + git --version + gh --version 2>/dev/null | head -1 || echo "gh: not installed" + node --version 2>/dev/null || echo "node: not installed" + npm --version 2>/dev/null || echo "npm: not installed" + go version 2>/dev/null || echo "go: not installed" + python --version 2>/dev/null || echo "python: not installed" + echo "" + echo "--- Agent instruction files ---" + if [ -f "AGENTS.md" ]; then + echo "✅ AGENTS.md ($(wc -l < AGENTS.md) lines)" + else + echo "❌ AGENTS.md — MISSING. Add this file per agent-standards.md." + exit 1 + fi + if [ -f ".github/copilot-instructions.md" ]; then + echo "✅ .github/copilot-instructions.md" + else + echo "ℹ️ .github/copilot-instructions.md — not present (optional but recommended)" + fi + # Count path-scoped instruction files (guard against missing directory) + INSTR_COUNT=0 + if [ -d ".github/instructions" ]; then + INSTR_COUNT=$(find .github/instructions -name "*.instructions.md" | wc -l) + fi + echo "ℹ️ .github/instructions/: ${INSTR_COUNT} file(s)" + echo "" + echo "✅ Setup complete — Copilot cloud agent is ready to work" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 35ad3e4ea..d3ff304ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,3 +20,9 @@ jobs: - name: Run list-prs sort tests run: bash tests/test_list_prs_sort.sh + + - name: Install test dependencies + run: pip install pyyaml + + - name: Run copilot-setup-steps workflow tests + run: bash tests/test_copilot_setup_steps.sh diff --git a/tests/test_copilot_setup_steps.sh b/tests/test_copilot_setup_steps.sh new file mode 100644 index 000000000..e50377a14 --- /dev/null +++ b/tests/test_copilot_setup_steps.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# tests/test_copilot_setup_steps.sh +# +# Validates that .github/workflows/copilot-setup-steps.yml exists and contains +# a job named exactly `copilot-setup-steps`, which GitHub requires to recognise +# the file for the Copilot cloud agent setup feature. +# +# Run: bash tests/test_copilot_setup_steps.sh + +set -euo pipefail + +if ! command -v python3 &>/dev/null; then + echo "ERROR: python3 is required to run this script." >&2 + exit 1 +fi + +if ! python3 -c "import yaml" &>/dev/null; then + echo "ERROR: python3 'yaml' module (PyYAML) is required to run this script." >&2 + exit 1 +fi + +PASS=0 +FAIL=0 +ERRORS="" + +ok() { + local name="$1" + PASS=$((PASS + 1)) + printf 'PASS %s\n' "$name" +} + +fail() { + local name="$1" + local msg="$2" + FAIL=$((FAIL + 1)) + ERRORS="${ERRORS}FAIL ${name}: ${msg}\n" + printf 'FAIL %s: %s\n' "$name" "$msg" +} + +WORKFLOW=".github/workflows/copilot-setup-steps.yml" +YAML_VALID=0 + +# --------------------------------------------------------------------------- +# Test 1: File exists +# --------------------------------------------------------------------------- +if [ -f "$WORKFLOW" ]; then + ok "copilot-setup-steps.yml exists" +else + fail "copilot-setup-steps.yml exists" "file not found at $WORKFLOW" +fi + +# --------------------------------------------------------------------------- +# Test 1.5: YAML is valid +# --------------------------------------------------------------------------- +if [ -f "$WORKFLOW" ]; then + if python3 - "$WORKFLOW" << 'PY' 2>/dev/null +import sys, yaml +with open(sys.argv[1]) as f: + yaml.safe_load(f) +PY + then + ok "copilot-setup-steps.yml is valid YAML" + YAML_VALID=1 + else + fail "copilot-setup-steps.yml is valid YAML" "failed to parse YAML file" + fi +fi + +# --------------------------------------------------------------------------- +# Test 2: Job named `copilot-setup-steps` is present +# GitHub requires this exact name to activate the workflow for Copilot agent. +# --------------------------------------------------------------------------- +if [ "$YAML_VALID" -eq 1 ]; then + if python3 - "$WORKFLOW" << 'PY' +import sys, yaml +with open(sys.argv[1]) as f: + wf = yaml.safe_load(f) or {} +jobs = wf.get("jobs") or {} +if "copilot-setup-steps" not in jobs: + print(f"jobs keys found: {list(jobs.keys())}", file=sys.stderr) + sys.exit(1) +PY + then + ok "job named 'copilot-setup-steps' is present" + else + fail "job named 'copilot-setup-steps' is present" \ + "no job with that exact name — GitHub will not recognise the workflow" + fi +fi + +# --------------------------------------------------------------------------- +# Test 3: No other job names (GitHub only uses the copilot-setup-steps job) +# --------------------------------------------------------------------------- +if [ "$YAML_VALID" -eq 1 ]; then + JOB_COUNT=$(python3 - "$WORKFLOW" << 'PY' +import sys, yaml +with open(sys.argv[1]) as f: + wf = yaml.safe_load(f) or {} +print(len(wf.get("jobs") or {})) +PY +) + if [[ "$JOB_COUNT" == "1" ]]; then + ok "workflow contains exactly 1 job" + else + fail "workflow contains exactly 1 job" "found $JOB_COUNT jobs — expected only copilot-setup-steps" + fi +fi + +# --------------------------------------------------------------------------- +# Test 4: timeout-minutes does not exceed 59 (GitHub hard limit) +# --------------------------------------------------------------------------- +if [ "$YAML_VALID" -eq 1 ]; then + TIMEOUT=$(python3 - "$WORKFLOW" << 'PY' +import sys, yaml +with open(sys.argv[1]) as f: + wf = yaml.safe_load(f) or {} +jobs = wf.get("jobs") or {} +job = jobs.get("copilot-setup-steps") or {} +print(job.get("timeout-minutes", 0)) +PY +) + if [[ "$TIMEOUT" =~ ^[0-9]+$ ]] && [ "$TIMEOUT" -le 59 ] && [ "$TIMEOUT" -gt 0 ]; then + ok "timeout-minutes ($TIMEOUT) is within GitHub's 59-minute limit" + else + fail "timeout-minutes within limit" \ + "timeout-minutes=$TIMEOUT is invalid (must be 1–59)" + fi +fi + +# --------------------------------------------------------------------------- +# Test 5: Top-level permissions are reset to {} (least-privilege policy) +# --------------------------------------------------------------------------- +if [ "$YAML_VALID" -eq 1 ]; then + PERMS=$(python3 - "$WORKFLOW" << 'PY' +import sys, yaml +with open(sys.argv[1]) as f: + wf = yaml.safe_load(f) or {} +perms = wf.get("permissions", "MISSING") +# {} or None (null) both represent "reset to empty" in YAML +if perms == {} or perms is None: + print("empty") +else: + print(repr(perms)) +PY +) + if [ "$PERMS" = "empty" ]; then + ok "top-level permissions reset to {}" + else + fail "top-level permissions reset to {}" \ + "got: $PERMS — ci-standards.md requires top-level permissions: {}" + fi +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + printf '%b' "$ERRORS" + exit 1 +fi