diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..7c883fc --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,84 @@ +# GitHub Actions Workflows + +This directory contains reusable and repo-specific GitHub Actions workflows for the privilegedescalation organization. + +## Available Tools on Runners + +### Always Available +- `curl` - HTTP client (use this instead of `gh` CLI for API calls) +- `jq` - JSON processor +- `bash` - Shell +- `git` - Version control +- `docker` / `podman` - Container runtime (depending on runner) + +### NOT Available (must install if needed) +- `gh` CLI - GitHub CLI is **not** pre-installed on runners. Use `curl` with the GitHub API instead. + +## Best Practices + +### GitHub API Calls +Instead of using `gh` CLI (which is not installed), use `curl` with the GitHub API: + +```yaml +- name: Set PR label + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + curl -sf \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${REPO}/issues/${PR_NUMBER}/labels" \ + -d '{"labels":["label-name"]}' +``` + +### Workflow Validation +Run actionlint locally before pushing: + +```bash +actionlint -color .github/workflows/*.yaml +``` + +### Reusable Workflows +- `plugin-ci.yaml` - Standard CI for Headlamp plugins +- `plugin-e2e.yaml` - E2E testing for Headlamp plugins +- `dual-approval-check.yaml` - Checks for CTO and QA approval +- `detect-pr-pipeline.yaml` - Detects Pipeline A vs Pipeline B based on changed files + +## Workflow Naming Convention + +- Use kebab-case: `my-workflow.yaml` +- Be descriptive: `plugin-ci.yaml` not `ci.yaml` +- For reusable workflows, keep the name clear about its purpose + +## Required Gates + +All PRs must pass: +1. `actionlint` validation (workflow YAML syntax) +2. Shell script validation (if scripts are used) +3. Any repo-specific CI checks + +## Common Patterns + +### Getting Changed Files +Use `tj-actions/changed-files`: + +```yaml +- name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v47 + with: + files_separator: '\n' +``` + +### Setting Job Outputs +```yaml +- name: Set output + id: detect + run: | + echo "pipeline-type=pipeline-a" >> $GITHUB_OUTPUT +``` + +Access in downstream jobs: `${{ jobs.job-name.outputs.pipeline-type }}` diff --git a/.github/workflows/detect-pr-pipeline.yaml b/.github/workflows/detect-pr-pipeline.yaml new file mode 100644 index 0000000..74f4cf3 --- /dev/null +++ b/.github/workflows/detect-pr-pipeline.yaml @@ -0,0 +1,65 @@ +name: Detect PR Pipeline Type + +on: + pull_request: + branches: [main, dev, uat] + workflow_call: + +permissions: + contents: read + pull-requests: write + +jobs: + test-detection-logic: + runs-on: runners-privilegedescalation + timeout-minutes: 2 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run detection tests + run: bash scripts/test-detect-pipeline.sh + + detect-pipeline: + runs-on: runners-privilegedescalation + timeout-minutes: 5 + outputs: + pipeline-type: ${{ steps.detect.outputs.pipeline-type }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v47 + with: + files_separator: '\n' + + - name: Detect pipeline type + id: detect + run: | + echo "Changed files:" + echo "${{ steps.changed-files.outputs.all_changed_files }}" + + pipeline=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | bash scripts/detect-pipeline.sh) + + echo "pipeline-type=$pipeline" >> $GITHUB_OUTPUT + echo "Detected pipeline: $pipeline" + + - name: Set PR label + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PIPELINE_TYPE: ${{ steps.detect.outputs.pipeline-type }} + run: | + curl -sf \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${REPO}/issues/${PR_NUMBER}/labels" \ + -d "{\"labels\":[\"${PIPELINE_TYPE}\"]}" diff --git a/scripts/detect-pipeline.sh b/scripts/detect-pipeline.sh new file mode 100755 index 0000000..a77e9a2 --- /dev/null +++ b/scripts/detect-pipeline.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reads a newline-separated list of changed files from stdin. +# Outputs "pipeline-a" or "pipeline-b" to stdout. +# Pipeline B: all files are infra-only (config, docs, workflows). +# Pipeline A: any non-infra file present. + +detect_pipeline() { + local all_infra=true + + while IFS= read -r file; do + [ -z "$file" ] && continue + + local filename + local dir + filename=$(basename "$file") + dir=$(dirname "$file") + + if [[ "$dir" == ".github" || "$dir" == .github/* ]] || \ + [[ "$dir" == "infra" || "$dir" == infra/* ]] || \ + [[ "$dir" == "org" || "$dir" == org/* ]] || \ + [[ "$filename" == *.md ]] || \ + [[ "$filename" == .eslintrc* ]] || \ + [[ "$filename" == .prettierrc* ]] || \ + [[ "$filename" == renovate.json* ]] || \ + [[ "$filename" == .gitignore ]] || \ + [[ "$filename" == .editorconfig ]] || \ + [[ "$filename" == LICENSE ]] || \ + [[ "$filename" == Dockerfile ]] || \ + [[ "$filename" == docker-compose* ]] || \ + [[ "$filename" == Makefile ]]; then + continue + else + all_infra=false + break + fi + done + + if [ "$all_infra" = true ]; then + echo "pipeline-b" + else + echo "pipeline-a" + fi +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + detect_pipeline +fi diff --git a/scripts/test-detect-pipeline.sh b/scripts/test-detect-pipeline.sh new file mode 100755 index 0000000..7245345 --- /dev/null +++ b/scripts/test-detect-pipeline.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/detect-pipeline.sh" + +PASS=0 +FAIL=0 + +assert_eq() { + local test_name="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS: $test_name" + PASS=$((PASS + 1)) + else + echo "FAIL: $test_name (expected=$expected, actual=$actual)" + FAIL=$((FAIL + 1)) + fi +} + +run_detect() { + echo "$1" | detect_pipeline +} + +# --- Pipeline B cases (infra-only) --- + +assert_eq "single .github root file" "pipeline-b" \ + "$(run_detect ".github/dependabot.yml")" + +assert_eq ".github/workflows subdirectory" "pipeline-b" \ + "$(run_detect ".github/workflows/ci.yaml")" + +assert_eq "deeply nested .github path" "pipeline-b" \ + "$(run_detect ".github/workflows/reusable/build.yaml")" + +assert_eq "markdown file at root" "pipeline-b" \ + "$(run_detect "README.md")" + +assert_eq "markdown in subdirectory" "pipeline-b" \ + "$(run_detect "docs/CONTRIBUTING.md")" + +assert_eq "eslintrc config" "pipeline-b" \ + "$(run_detect ".eslintrc.json")" + +assert_eq "prettierrc config" "pipeline-b" \ + "$(run_detect ".prettierrc.yaml")" + +assert_eq "renovate config" "pipeline-b" \ + "$(run_detect "renovate.json")" + +assert_eq "renovate config5" "pipeline-b" \ + "$(run_detect "renovate.json5")" + +assert_eq "gitignore" "pipeline-b" \ + "$(run_detect ".gitignore")" + +assert_eq "editorconfig" "pipeline-b" \ + "$(run_detect ".editorconfig")" + +assert_eq "LICENSE" "pipeline-b" \ + "$(run_detect "LICENSE")" + +assert_eq "mixed infra files" "pipeline-b" \ + "$(run_detect ".github/workflows/ci.yaml +README.md +.eslintrc.json +LICENSE")" + +assert_eq "workflow + markdown combo" "pipeline-b" \ + "$(run_detect ".github/workflows/detect-pr-pipeline.yaml +.github/workflows/README.md")" + +assert_eq "infra root file" "pipeline-b" \ + "$(run_detect "infra/helmrelease.yaml")" + +assert_eq "infra nested file" "pipeline-b" \ + "$(run_detect "infra/clusters/prod/kustomization.yaml")" + +assert_eq "org root file" "pipeline-b" \ + "$(run_detect "org/CODEOWNERS")" + +assert_eq "org nested file" "pipeline-b" \ + "$(run_detect "org/policies/branch-protection.json")" + +assert_eq "Dockerfile" "pipeline-b" \ + "$(run_detect "Dockerfile")" + +assert_eq "docker-compose.yaml" "pipeline-b" \ + "$(run_detect "docker-compose.yaml")" + +assert_eq "docker-compose.override.yml" "pipeline-b" \ + "$(run_detect "docker-compose.override.yml")" + +assert_eq "Makefile" "pipeline-b" \ + "$(run_detect "Makefile")" + +assert_eq "mixed infra + org + workflow" "pipeline-b" \ + "$(run_detect ".github/workflows/ci.yaml +infra/helmrelease.yaml +org/CODEOWNERS +README.md")" + +# --- Pipeline A cases (has non-infra files) --- + +assert_eq "plugin source file" "pipeline-a" \ + "$(run_detect "headlamp-polaris-plugin/src/index.tsx")" + +assert_eq "plugin package.json" "pipeline-a" \ + "$(run_detect "headlamp-polaris-plugin/package.json")" + +assert_eq "root source file" "pipeline-a" \ + "$(run_detect "src/main.ts")" + +assert_eq "mixed infra + code" "pipeline-a" \ + "$(run_detect ".github/workflows/ci.yaml +headlamp-polaris-plugin/src/index.tsx +README.md")" + +assert_eq "single non-infra file" "pipeline-a" \ + "$(run_detect "server.js")" + +assert_eq "plugin code + infra files" "pipeline-a" \ + "$(run_detect "infra/helmrelease.yaml +org/CODEOWNERS +headlamp-polaris-plugin/src/index.tsx")" + +# --- Edge cases --- + +assert_eq "empty input" "pipeline-b" \ + "$(run_detect "")" + +assert_eq "root dot file (not in infra list)" "pipeline-a" \ + "$(run_detect ".env")" + +assert_eq ".github-like but not .github dir" "pipeline-a" \ + "$(run_detect ".github-backup/config.yaml")" + +# --- Summary --- + +echo "" +echo "Results: $PASS passed, $FAIL failed" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi