From 733cfad8d3b53ce9d8f5e55e1cebc65d24f89861 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 19:47:01 +0000 Subject: [PATCH 1/8] inline(release): replace broken reusable workflow with inlined steps The reusable workflow reference to privilegedescalation/.github does not exist on Gitea, blocking the v1.0.1 release. This change inlines the build/package/release steps directly into release.yaml. Steps inlined: - actions/checkout@v4 - actions/setup-node@v4 (Node 20, pnpm cache) - pnpm install --frozen-lockfile - pnpm run build - pnpm run package (produces headlamp-polaris-{version}.tgz) - Gitea API: create release + upload tarball as asset Refs: PRI-1659, PRI-1634 --- .github/workflows/release.yaml | 75 ++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 44f28f3..abb903d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,20 +4,77 @@ on: workflow_dispatch: inputs: version: - description: 'Release version (e.g. 1.0.0)' + description: 'Release version (e.g. 1.0.1)' required: true type: string permissions: contents: write - pull-requests: write jobs: release: - uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main - secrets: - RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - with: - version: ${{ inputs.version }} - upstream-repo: 'FairwindsOps/polaris' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Get tarball path + id: tarball + run: | + # headlamp-plugin package outputs the tarball path, e.g.: + # "Packaged: /path/to/headlamp-polaris-1.0.0.tgz" + output=$(pnpm run package 2>&1) + echo "output=$output" + # Extract tarball name, e.g. headlamp-polaris-1.0.0.tgz + tarball_name=$(echo "$output" | grep -oP 'headlamp-polaris-\d+\.\d+\.\d+\.tgz' | tail -1) + echo "tarball_name=$tarball_name" >> $GITHUB_OUTPUT + + - name: Create Gitea Release + env: + GITEA_URL: https://git.farh.net + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + REPO: privilegedescalation/headlamp-polaris-plugin + run: | + VERSION="${{ inputs.version }}" + ASSET_NAME="headlamp-polaris-${VERSION}.tar.gz" + + # Create the release via Gitea API + RELEASE_RESPONSE=$( + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases" \ + -d "{ + \"tag_name\": \"v${VERSION}\", + \"name\": \"v${VERSION}\", + \"draft\": false, + \"prerelease\": false + }" + ) + echo "Release response: ${RELEASE_RESPONSE}" + + RELEASE_ID=$(echo "${RELEASE_RESPONSE}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))") + if [ -z "$RELEASE_ID" ]; then + echo "Failed to create release" + exit 1 + fi + + # Upload the tarball asset + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + -T "${{ steps.tarball.outputs.tarball_name }}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ASSET_NAME}" \ No newline at end of file From cf9e0513b90e7e6dd3ee5e7f16b9c1d6a3fcd4cd Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 19:53:35 +0000 Subject: [PATCH 2/8] fix(CI): inline ci.yaml, remove broken reusable workflow reference (PRI-1660) --- .github/workflows/ci.yaml | 201 +++++++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 654169d..abd2b3a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,6 +8,205 @@ on: workflow_dispatch: workflow_call: +permissions: + contents: read + jobs: ci: - uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main + runs-on: ubuntu-latest + timeout-minutes: 10 + container: node:22-slim + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Python + run: apt-get update && apt-get install -y --no-install-recommends python3 python3-yaml + + - name: Validate artifacthub-pkg.yml + run: | + python3 - <<'EOF' + import sys, re + try: + import yaml + except ImportError: + print("::warning::PyYAML not available, skipping artifacthub-pkg.yml validation") + sys.exit(0) + + try: + with open("artifacthub-pkg.yml") as f: + pkg = yaml.safe_load(f) + except FileNotFoundError: + print("::error::artifacthub-pkg.yml not found") + sys.exit(1) + except yaml.YAMLError as e: + print(f"::error::artifacthub-pkg.yml is invalid YAML: {e}") + sys.exit(1) + + errors = [] + + for field in ["version", "name", "description", "homeURL"]: + if not pkg.get(field): + errors.append(f"Missing required field: {field}") + + version = pkg.get("version", "") + if version and not re.match(r'^\d+\.\d+\.\d+$', str(version)): + errors.append(f"version '{version}' is not SemVer (expected X.Y.Z)") + + annotations = pkg.get("annotations", {}) or {} + archive_url = annotations.get("headlamp/plugin/archive-url", "") + archive_checksum = annotations.get("headlamp/plugin/archive-checksum", "") + + if not archive_url: + errors.append("Missing annotation: headlamp/plugin/archive-url") + if not archive_checksum: + errors.append("Missing annotation: headlamp/plugin/archive-checksum") + elif not re.match(r'^sha256:[0-9a-f]{64}$', str(archive_checksum)): + errors.append(f"archive-checksum has unexpected format: '{archive_checksum}' (expected sha256:<64 hex chars>)") + + if errors: + for e in errors: + print(f"::error::{e}") + sys.exit(1) + + print(f"artifacthub-pkg.yml valid: name={pkg['name']} version={pkg['version']}") + EOF + + - name: Detect package manager + id: pkg-manager + run: | + if [ -f "pnpm-lock.yaml" ]; then + echo "manager=pnpm" >> $GITHUB_OUTPUT + PM=$(python3 -c "import json,sys; d=json.load(open('package.json')); print('true' if d.get('packageManager','').startswith('pnpm@') else 'false')" 2>/dev/null || echo "false") + echo "has_package_manager=$PM" >> $GITHUB_OUTPUT + else + echo "manager=npm" >> $GITHUB_OUTPUT + echo "has_package_manager=false" >> $GITHUB_OUTPUT + fi + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: ${{ steps.pkg-manager.outputs.manager == 'npm' && 'npm' || '' }} + + - name: Setup pnpm (via Corepack, reads version from packageManager field) + if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'true' + run: | + npm install -g corepack + corepack enable pnpm + corepack install + + - name: Setup pnpm (version latest) + if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'false' + uses: pnpm/action-setup@v5 + with: + run_install: false + version: latest + + - name: Get pnpm store directory + id: pnpm-store + if: steps.pkg-manager.outputs.manager == 'pnpm' + run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + + - name: Cache pnpm store + if: steps.pkg-manager.outputs.manager == 'pnpm' + uses: actions/cache@v5 + with: + path: ${{ steps.pnpm-store.outputs.dir }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Validate pnpm lockfile freshness + if: steps.pkg-manager.outputs.manager == 'pnpm' + run: | + if [ ! -f "pnpm-lock.yaml" ]; then + echo "No pnpm-lock.yaml found, skipping lockfile freshness check" + exit 0 + fi + if ! grep -q 'overrides:' pnpm-lock.yaml 2>/dev/null; then + echo "No overrides section in pnpm-lock.yaml, skipping lockfile freshness check" + exit 0 + fi + echo "Detected pnpm-lock.yaml with overrides section. Checking lockfile freshness..." + ERR_FILE=$(mktemp) + if pnpm install --frozen-lockfile 2>&1 | tee "$ERR_FILE"; then + echo "Lockfile is fresh." + else + if grep -q "CONFIG_MISMATCH\|EBADLOCKFILE\|ERR_PNPM_LOCKFILE" "$ERR_FILE"; then + echo "" + echo "::error::pnpm-lock.yaml is out of sync with package.json overrides." + echo "::error::Run 'pnpm install' to regenerate the lockfile and commit the updated pnpm-lock.yaml." + rm -f "$ERR_FILE" + exit 1 + fi + rm -f "$ERR_FILE" + echo "::warning::Install failed with a different error. Will retry in the Install dependencies step." + fi + + - name: Install dependencies + run: | + max_attempts=3 + attempt=1 + while [ $attempt -le $max_attempts ]; do + echo "Attempt $attempt of $max_attempts" + if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then + pnpm install --frozen-lockfile && break + else + npm ci && break + fi + if [ $attempt -lt $max_attempts ]; then + echo "::warning::Install step failed on attempt $attempt. Retrying in 5 seconds..." + sleep 5 + fi + attempt=$((attempt + 1)) + done + if [ $attempt -gt $max_attempts ]; then + echo "::error::Install step failed after $max_attempts attempts." + exit 1 + fi + + - name: Build plugin + run: npx @kinvolk/headlamp-plugin build + + - name: Lint + run: | + if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then + pnpm run lint + else + npm run lint + fi + + - name: Type-check + run: | + if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then + pnpm run tsc + else + npm run tsc + fi + + - name: Format check + run: | + if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then + pnpm run format:check + else + npm run format:check + fi + + - name: Run tests + run: | + if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then + pnpm test + else + npm test + fi + + - name: Security audit + run: | + if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then + npx audit-ci --pnpm --audit-level=high --config ./audit-ci.jsonc + else + npx audit-ci --npm --audit-level=high --config ./audit-ci.jsonc + fi \ No newline at end of file From 92f8c958d8a554d12d1b240a783f40aaa6acf9ec Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 20:19:01 +0000 Subject: [PATCH 3/8] fix(release): inline release workflow, remove broken .github reference (PRI-1660) --- .github/workflows/release.yaml | 75 ++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 44f28f3..abb903d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,20 +4,77 @@ on: workflow_dispatch: inputs: version: - description: 'Release version (e.g. 1.0.0)' + description: 'Release version (e.g. 1.0.1)' required: true type: string permissions: contents: write - pull-requests: write jobs: release: - uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main - secrets: - RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - with: - version: ${{ inputs.version }} - upstream-repo: 'FairwindsOps/polaris' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Get tarball path + id: tarball + run: | + # headlamp-plugin package outputs the tarball path, e.g.: + # "Packaged: /path/to/headlamp-polaris-1.0.0.tgz" + output=$(pnpm run package 2>&1) + echo "output=$output" + # Extract tarball name, e.g. headlamp-polaris-1.0.0.tgz + tarball_name=$(echo "$output" | grep -oP 'headlamp-polaris-\d+\.\d+\.\d+\.tgz' | tail -1) + echo "tarball_name=$tarball_name" >> $GITHUB_OUTPUT + + - name: Create Gitea Release + env: + GITEA_URL: https://git.farh.net + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + REPO: privilegedescalation/headlamp-polaris-plugin + run: | + VERSION="${{ inputs.version }}" + ASSET_NAME="headlamp-polaris-${VERSION}.tar.gz" + + # Create the release via Gitea API + RELEASE_RESPONSE=$( + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases" \ + -d "{ + \"tag_name\": \"v${VERSION}\", + \"name\": \"v${VERSION}\", + \"draft\": false, + \"prerelease\": false + }" + ) + echo "Release response: ${RELEASE_RESPONSE}" + + RELEASE_ID=$(echo "${RELEASE_RESPONSE}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))") + if [ -z "$RELEASE_ID" ]; then + echo "Failed to create release" + exit 1 + fi + + # Upload the tarball asset + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + -T "${{ steps.tarball.outputs.tarball_name }}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ASSET_NAME}" \ No newline at end of file From 48d704a6b6a528d2b9870de60d69f0ed32112cde Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 20:20:45 +0000 Subject: [PATCH 4/8] fix(promotion-gate): inline dual-approval-check workflow (PRI-1660) --- .github/workflows/dual-approval.yaml | 105 ++------------------------- 1 file changed, 5 insertions(+), 100 deletions(-) diff --git a/.github/workflows/dual-approval.yaml b/.github/workflows/dual-approval.yaml index 4412e78..9552ee4 100644 --- a/.github/workflows/dual-approval.yaml +++ b/.github/workflows/dual-approval.yaml @@ -1,5 +1,6 @@ name: Promotion Gate +# Calls the shared promotion gate workflow. # dev PRs: no gate (engineer self-merges). # uat PRs: QA approval required. # main PRs: UAT approval required (uat→main promotions). @@ -13,104 +14,8 @@ on: jobs: promotion-gate: - name: Promotion Gate - runs-on: ubuntu-latest - container: ubuntu:latest - timeout-minutes: 5 + uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main + secrets: inherit + with: + pr_number: ${{ github.event.pull_request.number }} - steps: - - name: Install dependencies - run: apt-get update -qq && apt-get install -y --no-install-recommends curl jq - - - name: Check promotion approval - env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - BASE_REF: ${{ github.base_ref }} - run: | - if [ -z "${PR_NUMBER}" ] || [ "${PR_NUMBER}" = "null" ]; then - echo "::notice::No PR number in context. Skipping promotion gate." - exit 0 - fi - - echo "Checking promotion gate for PR #${PR_NUMBER} targeting ${BASE_REF} in ${REPO}" - - if [ -z "${BASE_REF}" ] && [ -n "${PR_NUMBER}" ] && [ "${PR_NUMBER}" != "null" ]; then - BASE_REF=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Accept: application/json" \ - "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.base.ref') - echo "BASE_REF was empty; resolved from PR #${PR_NUMBER} API: ${BASE_REF}" - fi - - # Determine required reviewer based on target branch - case "${BASE_REF}" in - dev) - echo "Target is dev — no review required. Engineers self-merge." - exit 0 - ;; - uat) - REQUIRED_REVIEWER="pe_regina" - GATE_NAME="QA" - ;; - main) - REQUIRED_REVIEWER="pe_regina" - GATE_NAME="QA" - # For plugin repos (Pipeline A), UAT approval is needed for uat→main - # Check if the source branch is uat - SOURCE_REF=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Accept: application/json" \ - "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.head.ref') - - if [ "${SOURCE_REF}" = "uat" ]; then - REQUIRED_REVIEWER="pe_patty" - GATE_NAME="UAT" - fi - ;; - *) - echo "::notice::Target branch '${BASE_REF}' has no promotion gate configured." - exit 0 - ;; - esac - - echo "Required reviewer: ${REQUIRED_REVIEWER} (${GATE_NAME})" - - # For uat→main promotions, pe_patty may not be able to review (bot account). - # Accept pe_nancy (CTO) as a valid alternative reviewer. - ALT_REVIEWER="" - if [ "${REQUIRED_REVIEWER}" = "pe_patty" ]; then - ALT_REVIEWER="pe_nancy" - fi - - REVIEWS=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Accept: application/json" \ - "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}/reviews") - - if [ -z "${REVIEWS}" ] || [ "${REVIEWS}" = "null" ]; then - echo "::warning::Could not fetch reviews for PR #${PR_NUMBER}." - exit 1 - fi - - REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${REQUIRED_REVIEWER}" \ - '[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end') - - echo "${GATE_NAME} (${REQUIRED_REVIEWER}) approved: ${REVIEWER_APPROVED}" - - # Fallback: check if CTO approved as alternative for uat→main - if [ "${REVIEWER_APPROVED}" != "true" ] && [ -n "${ALT_REVIEWER}" ]; then - REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${ALT_REVIEWER}" \ - '[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end') - if [ "${REVIEWER_APPROVED}" = "true" ]; then - echo "CTO (${ALT_REVIEWER}) approved as fallback for UAT gate." - fi - fi - - if [ "${REVIEWER_APPROVED}" = "true" ]; then - echo "Promotion gate passed: ${GATE_NAME} has approved." - else - echo "Promotion gate failed: waiting for ${GATE_NAME} approval from ${REQUIRED_REVIEWER}." - exit 1 - fi \ No newline at end of file From 51e68b1b885ea23ce88f966bdc8d5ee32ce7b945 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 20:20:45 +0000 Subject: [PATCH 5/8] fix(promotion-gate): inline dual-approval-check workflow (PRI-1660) --- .github/workflows/dual-approval.yaml | 105 ++------------------------- 1 file changed, 5 insertions(+), 100 deletions(-) diff --git a/.github/workflows/dual-approval.yaml b/.github/workflows/dual-approval.yaml index 4412e78..9552ee4 100644 --- a/.github/workflows/dual-approval.yaml +++ b/.github/workflows/dual-approval.yaml @@ -1,5 +1,6 @@ name: Promotion Gate +# Calls the shared promotion gate workflow. # dev PRs: no gate (engineer self-merges). # uat PRs: QA approval required. # main PRs: UAT approval required (uat→main promotions). @@ -13,104 +14,8 @@ on: jobs: promotion-gate: - name: Promotion Gate - runs-on: ubuntu-latest - container: ubuntu:latest - timeout-minutes: 5 + uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main + secrets: inherit + with: + pr_number: ${{ github.event.pull_request.number }} - steps: - - name: Install dependencies - run: apt-get update -qq && apt-get install -y --no-install-recommends curl jq - - - name: Check promotion approval - env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - BASE_REF: ${{ github.base_ref }} - run: | - if [ -z "${PR_NUMBER}" ] || [ "${PR_NUMBER}" = "null" ]; then - echo "::notice::No PR number in context. Skipping promotion gate." - exit 0 - fi - - echo "Checking promotion gate for PR #${PR_NUMBER} targeting ${BASE_REF} in ${REPO}" - - if [ -z "${BASE_REF}" ] && [ -n "${PR_NUMBER}" ] && [ "${PR_NUMBER}" != "null" ]; then - BASE_REF=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Accept: application/json" \ - "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.base.ref') - echo "BASE_REF was empty; resolved from PR #${PR_NUMBER} API: ${BASE_REF}" - fi - - # Determine required reviewer based on target branch - case "${BASE_REF}" in - dev) - echo "Target is dev — no review required. Engineers self-merge." - exit 0 - ;; - uat) - REQUIRED_REVIEWER="pe_regina" - GATE_NAME="QA" - ;; - main) - REQUIRED_REVIEWER="pe_regina" - GATE_NAME="QA" - # For plugin repos (Pipeline A), UAT approval is needed for uat→main - # Check if the source branch is uat - SOURCE_REF=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Accept: application/json" \ - "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.head.ref') - - if [ "${SOURCE_REF}" = "uat" ]; then - REQUIRED_REVIEWER="pe_patty" - GATE_NAME="UAT" - fi - ;; - *) - echo "::notice::Target branch '${BASE_REF}' has no promotion gate configured." - exit 0 - ;; - esac - - echo "Required reviewer: ${REQUIRED_REVIEWER} (${GATE_NAME})" - - # For uat→main promotions, pe_patty may not be able to review (bot account). - # Accept pe_nancy (CTO) as a valid alternative reviewer. - ALT_REVIEWER="" - if [ "${REQUIRED_REVIEWER}" = "pe_patty" ]; then - ALT_REVIEWER="pe_nancy" - fi - - REVIEWS=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Accept: application/json" \ - "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}/reviews") - - if [ -z "${REVIEWS}" ] || [ "${REVIEWS}" = "null" ]; then - echo "::warning::Could not fetch reviews for PR #${PR_NUMBER}." - exit 1 - fi - - REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${REQUIRED_REVIEWER}" \ - '[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end') - - echo "${GATE_NAME} (${REQUIRED_REVIEWER}) approved: ${REVIEWER_APPROVED}" - - # Fallback: check if CTO approved as alternative for uat→main - if [ "${REVIEWER_APPROVED}" != "true" ] && [ -n "${ALT_REVIEWER}" ]; then - REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${ALT_REVIEWER}" \ - '[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end') - if [ "${REVIEWER_APPROVED}" = "true" ]; then - echo "CTO (${ALT_REVIEWER}) approved as fallback for UAT gate." - fi - fi - - if [ "${REVIEWER_APPROVED}" = "true" ]; then - echo "Promotion gate passed: ${GATE_NAME} has approved." - else - echo "Promotion gate failed: waiting for ${GATE_NAME} approval from ${REQUIRED_REVIEWER}." - exit 1 - fi \ No newline at end of file From 73b2baec9dbc02b399d128a297fdbe3a5bcd9583 Mon Sep 17 00:00:00 2001 From: Null Pointer Nancy <8+pe_nancy@noreply.git.farh.net> Date: Wed, 20 May 2026 20:36:27 +0000 Subject: [PATCH 6/8] fix(promotion-gate): restore inlined dual-approval from main (PRI-1660) PR #170 merged conflict with old uat version instead of inlined dev version. Restore inlined dual-approval.yaml to match main, fixing uat->main promotion gate. Co-Authored-By: Paperclip --- .github/workflows/dual-approval.yaml | 105 +++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dual-approval.yaml b/.github/workflows/dual-approval.yaml index 9552ee4..4412e78 100644 --- a/.github/workflows/dual-approval.yaml +++ b/.github/workflows/dual-approval.yaml @@ -1,6 +1,5 @@ name: Promotion Gate -# Calls the shared promotion gate workflow. # dev PRs: no gate (engineer self-merges). # uat PRs: QA approval required. # main PRs: UAT approval required (uat→main promotions). @@ -14,8 +13,104 @@ on: jobs: promotion-gate: - uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main - secrets: inherit - with: - pr_number: ${{ github.event.pull_request.number }} + name: Promotion Gate + runs-on: ubuntu-latest + container: ubuntu:latest + timeout-minutes: 5 + steps: + - name: Install dependencies + run: apt-get update -qq && apt-get install -y --no-install-recommends curl jq + + - name: Check promotion approval + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.base_ref }} + run: | + if [ -z "${PR_NUMBER}" ] || [ "${PR_NUMBER}" = "null" ]; then + echo "::notice::No PR number in context. Skipping promotion gate." + exit 0 + fi + + echo "Checking promotion gate for PR #${PR_NUMBER} targeting ${BASE_REF} in ${REPO}" + + if [ -z "${BASE_REF}" ] && [ -n "${PR_NUMBER}" ] && [ "${PR_NUMBER}" != "null" ]; then + BASE_REF=$(curl -sf \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Accept: application/json" \ + "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.base.ref') + echo "BASE_REF was empty; resolved from PR #${PR_NUMBER} API: ${BASE_REF}" + fi + + # Determine required reviewer based on target branch + case "${BASE_REF}" in + dev) + echo "Target is dev — no review required. Engineers self-merge." + exit 0 + ;; + uat) + REQUIRED_REVIEWER="pe_regina" + GATE_NAME="QA" + ;; + main) + REQUIRED_REVIEWER="pe_regina" + GATE_NAME="QA" + # For plugin repos (Pipeline A), UAT approval is needed for uat→main + # Check if the source branch is uat + SOURCE_REF=$(curl -sf \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Accept: application/json" \ + "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.head.ref') + + if [ "${SOURCE_REF}" = "uat" ]; then + REQUIRED_REVIEWER="pe_patty" + GATE_NAME="UAT" + fi + ;; + *) + echo "::notice::Target branch '${BASE_REF}' has no promotion gate configured." + exit 0 + ;; + esac + + echo "Required reviewer: ${REQUIRED_REVIEWER} (${GATE_NAME})" + + # For uat→main promotions, pe_patty may not be able to review (bot account). + # Accept pe_nancy (CTO) as a valid alternative reviewer. + ALT_REVIEWER="" + if [ "${REQUIRED_REVIEWER}" = "pe_patty" ]; then + ALT_REVIEWER="pe_nancy" + fi + + REVIEWS=$(curl -sf \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Accept: application/json" \ + "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}/reviews") + + if [ -z "${REVIEWS}" ] || [ "${REVIEWS}" = "null" ]; then + echo "::warning::Could not fetch reviews for PR #${PR_NUMBER}." + exit 1 + fi + + REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${REQUIRED_REVIEWER}" \ + '[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end') + + echo "${GATE_NAME} (${REQUIRED_REVIEWER}) approved: ${REVIEWER_APPROVED}" + + # Fallback: check if CTO approved as alternative for uat→main + if [ "${REVIEWER_APPROVED}" != "true" ] && [ -n "${ALT_REVIEWER}" ]; then + REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${ALT_REVIEWER}" \ + '[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end') + if [ "${REVIEWER_APPROVED}" = "true" ]; then + echo "CTO (${ALT_REVIEWER}) approved as fallback for UAT gate." + fi + fi + + if [ "${REVIEWER_APPROVED}" = "true" ]; then + echo "Promotion gate passed: ${GATE_NAME} has approved." + else + echo "Promotion gate failed: waiting for ${GATE_NAME} approval from ${REQUIRED_REVIEWER}." + exit 1 + fi \ No newline at end of file From 2aff05b6323e6b309f2683a243d0f989713a6006 Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard <9+pe_gandalf@noreply.git.farh.net> Date: Wed, 20 May 2026 21:01:16 +0000 Subject: [PATCH 7/8] fix(ci): use github.head_ref for SOURCE_REF detection in promotion gate Co-Authored-By: Paperclip --- .github/workflows/dual-approval.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dual-approval.yaml b/.github/workflows/dual-approval.yaml index 4412e78..8718611 100644 --- a/.github/workflows/dual-approval.yaml +++ b/.github/workflows/dual-approval.yaml @@ -28,6 +28,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} run: | if [ -z "${PR_NUMBER}" ] || [ "${PR_NUMBER}" = "null" ]; then echo "::notice::No PR number in context. Skipping promotion gate." @@ -59,10 +60,7 @@ jobs: GATE_NAME="QA" # For plugin repos (Pipeline A), UAT approval is needed for uat→main # Check if the source branch is uat - SOURCE_REF=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Accept: application/json" \ - "https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.head.ref') + SOURCE_REF="${HEAD_REF}" if [ "${SOURCE_REF}" = "uat" ]; then REQUIRED_REVIEWER="pe_patty" @@ -113,4 +111,4 @@ jobs: else echo "Promotion gate failed: waiting for ${GATE_NAME} approval from ${REQUIRED_REVIEWER}." exit 1 - fi \ No newline at end of file + fi From bfeb1068bb712d50b7b96e17e14f1458c57e153a Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 21:20:53 +0000 Subject: [PATCH 8/8] fix(ci): add ca-certificates for SSL verification in promotion gate Co-Authored-By: Paperclip --- .github/workflows/dual-approval.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dual-approval.yaml b/.github/workflows/dual-approval.yaml index 8718611..ea7cbc3 100644 --- a/.github/workflows/dual-approval.yaml +++ b/.github/workflows/dual-approval.yaml @@ -20,7 +20,7 @@ jobs: steps: - name: Install dependencies - run: apt-get update -qq && apt-get install -y --no-install-recommends curl jq + run: apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates curl jq - name: Check promotion approval env: