From cb0735b5cf50f354559e3d6ddcb8eacbfab4a96e Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Tue, 2 Dec 2025 16:04:52 -0500 Subject: [PATCH 1/2] =?UTF-8?q?ci:=20Sanitize=20upstream=20workflow=20trig?= =?UTF-8?q?gers=20during=20sync\n\n=F0=9F=A4=96=20Generated=20with=20[Nori?= =?UTF-8?q?](https://nori.ai)\n\nCo-Authored-By:=20Nori=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds workflow sanitization to upstream-sync.yml that replaces all on: trigger blocks with workflow_dispatch in pulled upstream workflows. This prevents upstream workflows from running automatically in fork branches. Changes: Add awk-based YAML processing, exclude fork-specific workflows, commit sanitization as part of sync branch, include sanitized file list in PR body, add test script. --- .github/workflows/upstream-sync.yml | 73 ++++++++++++ .../fixtures/expected-comments.yml | 10 ++ .../fixtures/expected-complex.yml | 12 ++ .../fixtures/expected-multiline.yml | 9 ++ .../fixtures/expected-singleline.yml | 9 ++ .../test-sanitize/fixtures/input-comments.yml | 15 +++ .../test-sanitize/fixtures/input-complex.yml | 29 +++++ .../fixtures/input-multiline.yml | 13 ++ .../fixtures/input-singleline.yml | 9 ++ scripts/test-sanitize/test-sanitize.sh | 112 ++++++++++++++++++ 10 files changed, 291 insertions(+) create mode 100644 scripts/test-sanitize/fixtures/expected-comments.yml create mode 100644 scripts/test-sanitize/fixtures/expected-complex.yml create mode 100644 scripts/test-sanitize/fixtures/expected-multiline.yml create mode 100644 scripts/test-sanitize/fixtures/expected-singleline.yml create mode 100644 scripts/test-sanitize/fixtures/input-comments.yml create mode 100644 scripts/test-sanitize/fixtures/input-complex.yml create mode 100644 scripts/test-sanitize/fixtures/input-multiline.yml create mode 100644 scripts/test-sanitize/fixtures/input-singleline.yml create mode 100755 scripts/test-sanitize/test-sanitize.sh diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 96191caef..23b30fbf2 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -122,6 +122,7 @@ jobs: - name: Create sync branch if: steps.check.outputs.exists == 'false' + id: sync run: | set -euo pipefail @@ -133,8 +134,60 @@ jobs: if [[ "$dry_run" == "true" ]]; then echo "::notice::DRY RUN: Would create branch $sync_branch from $target_tag" + echo "sanitized_files=" >> "$GITHUB_OUTPUT" else git checkout -b "$sync_branch" "$target_tag" + + # Sanitize upstream workflow triggers to prevent unwanted runs + sanitized_files="" + for workflow in .github/workflows/*.yml .github/workflows/*.yaml; do + [ -f "$workflow" ] || continue + + # Skip our fork-specific workflows + case "$(basename "$workflow")" in + upstream-sync.yml|rust-ci.yml) continue ;; + esac + + echo "Sanitizing: $workflow" + + # Replace on: block with workflow_dispatch only using awk + awk ' + /^on:/ { + in_on = 1 + print "on: workflow_dispatch" + next + } + in_on && /^$/ { + in_on = 0 + print + next + } + in_on && /^[^ \t#]/ { + in_on = 0 + } + in_on && /^[ \t]/ { + next + } + in_on && /^#/ { + next + } + !in_on { print } + ' "$workflow" > "${workflow}.tmp" && mv "${workflow}.tmp" "$workflow" + + sanitized_files="$sanitized_files- \`$(basename "$workflow")\`\n" + done + + if [[ -n "$sanitized_files" ]]; then + git add .github/workflows/ + git commit -m "chore: sanitize upstream workflow triggers for fork safety" + echo "sanitized_files<> "$GITHUB_OUTPUT" + echo -e "$sanitized_files" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + else + echo "::notice::No upstream workflows to sanitize" + echo "sanitized_files=" >> "$GITHUB_OUTPUT" + fi + git push origin "$sync_branch" fi @@ -144,6 +197,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TARGET_TAG: ${{ steps.tag.outputs.target_tag }} SYNC_BRANCH: ${{ steps.tag.outputs.sync_branch }} + SANITIZED_FILES: ${{ steps.sync.outputs.sanitized_files }} DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail @@ -153,6 +207,16 @@ jobs: pr_title="Sync upstream $TARGET_TAG" + # Build sanitized workflows section + if [[ -n "$SANITIZED_FILES" ]]; then + sanitized_section="### Workflow Sanitization + + The following upstream workflows had their triggers replaced with \\\`workflow_dispatch\\\`: + $SANITIZED_FILES" + else + sanitized_section="" + fi + # Build PR body using heredoc pr_body=$(cat <> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" @@ -208,3 +276,8 @@ jobs: echo "- **Sync branch:** ${{ steps.tag.outputs.sync_branch }}" >> "$GITHUB_STEP_SUMMARY" echo "- **Branch existed:** ${{ steps.check.outputs.exists }}" >> "$GITHUB_STEP_SUMMARY" echo "- **Dry run:** ${{ inputs.dry_run || 'false' }}" >> "$GITHUB_STEP_SUMMARY" + if [[ -n "$SANITIZED_FILES" ]]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "### Sanitized Workflows" >> "$GITHUB_STEP_SUMMARY" + echo "$SANITIZED_FILES" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/scripts/test-sanitize/fixtures/expected-comments.yml b/scripts/test-sanitize/fixtures/expected-comments.yml new file mode 100644 index 000000000..56f3872e4 --- /dev/null +++ b/scripts/test-sanitize/fixtures/expected-comments.yml @@ -0,0 +1,10 @@ +# This workflow runs on push +name: With Comments + +on: workflow_dispatch + +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "test" diff --git a/scripts/test-sanitize/fixtures/expected-complex.yml b/scripts/test-sanitize/fixtures/expected-complex.yml new file mode 100644 index 000000000..9d66ad03d --- /dev/null +++ b/scripts/test-sanitize/fixtures/expected-complex.yml @@ -0,0 +1,12 @@ +name: Complex Workflow + +on: workflow_dispatch + +env: + NODE_VERSION: '20' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 diff --git a/scripts/test-sanitize/fixtures/expected-multiline.yml b/scripts/test-sanitize/fixtures/expected-multiline.yml new file mode 100644 index 000000000..f500aef5e --- /dev/null +++ b/scripts/test-sanitize/fixtures/expected-multiline.yml @@ -0,0 +1,9 @@ +name: CI + +on: workflow_dispatch + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 diff --git a/scripts/test-sanitize/fixtures/expected-singleline.yml b/scripts/test-sanitize/fixtures/expected-singleline.yml new file mode 100644 index 000000000..be91c9760 --- /dev/null +++ b/scripts/test-sanitize/fixtures/expected-singleline.yml @@ -0,0 +1,9 @@ +name: Simple + +on: workflow_dispatch + +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "hello" diff --git a/scripts/test-sanitize/fixtures/input-comments.yml b/scripts/test-sanitize/fixtures/input-comments.yml new file mode 100644 index 000000000..dc1e52ba4 --- /dev/null +++ b/scripts/test-sanitize/fixtures/input-comments.yml @@ -0,0 +1,15 @@ +# This workflow runs on push +name: With Comments + +on: + # Run on push to main + push: + branches: [main] + # Also on PRs + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "test" diff --git a/scripts/test-sanitize/fixtures/input-complex.yml b/scripts/test-sanitize/fixtures/input-complex.yml new file mode 100644 index 000000000..8ec3c985c --- /dev/null +++ b/scripts/test-sanitize/fixtures/input-complex.yml @@ -0,0 +1,29 @@ +name: Complex Workflow + +on: + push: + branches: + - main + - 'release/**' + paths: + - 'src/**' + - '!src/**/*.md' + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + inputs: + debug: + description: 'Enable debug mode' + required: false + type: boolean + +env: + NODE_VERSION: '20' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 diff --git a/scripts/test-sanitize/fixtures/input-multiline.yml b/scripts/test-sanitize/fixtures/input-multiline.yml new file mode 100644 index 000000000..d8bb21404 --- /dev/null +++ b/scripts/test-sanitize/fixtures/input-multiline.yml @@ -0,0 +1,13 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 diff --git a/scripts/test-sanitize/fixtures/input-singleline.yml b/scripts/test-sanitize/fixtures/input-singleline.yml new file mode 100644 index 000000000..a42f56f77 --- /dev/null +++ b/scripts/test-sanitize/fixtures/input-singleline.yml @@ -0,0 +1,9 @@ +name: Simple + +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "hello" diff --git a/scripts/test-sanitize/test-sanitize.sh b/scripts/test-sanitize/test-sanitize.sh new file mode 100755 index 000000000..caf876a62 --- /dev/null +++ b/scripts/test-sanitize/test-sanitize.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Test script for workflow sanitization logic +# RED phase: This test will fail until we implement the sanitize function + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_DIR="$SCRIPT_DIR/fixtures" +TEMP_DIR="$(mktemp -d)" + +trap "rm -rf $TEMP_DIR" EXIT + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +failed=0 +passed=0 + +# The sanitize function +sanitize_workflow() { + local input_file="$1" + local output_file="$2" + + # Replace on: block with workflow_dispatch only using awk + # Logic: + # 1. When we see ^on: (at column 0), enter "in_on" mode, print replacement + # 2. While in_on, skip lines starting with whitespace (indented on: content) + # 3. When we see a non-whitespace line at column 0, exit in_on mode + # 4. Print all other lines normally + awk ' + /^on:/ { + in_on = 1 + print "on: workflow_dispatch" + next + } + in_on && /^$/ { + # Empty line ends the on: block + in_on = 0 + print + next + } + in_on && /^[^ \t#]/ { + # Non-indented line ends the on: block + in_on = 0 + } + in_on && /^[ \t]/ { + # Indented content - skip + next + } + in_on && /^#/ { + # Comment inside on: block - skip + next + } + !in_on { print } + ' "$input_file" > "$output_file" +} + +# Test helper +assert_output() { + local test_name="$1" + local input_file="$2" + local expected_file="$3" + + local actual_file="$TEMP_DIR/actual.yml" + sanitize_workflow "$input_file" "$actual_file" + + if diff -q "$expected_file" "$actual_file" > /dev/null 2>&1; then + echo -e "${GREEN}✓ PASS${NC}: $test_name" + passed=$((passed + 1)) + else + echo -e "${RED}✗ FAIL${NC}: $test_name" + echo " Expected:" + sed 's/^/ /' "$expected_file" + echo " Actual:" + sed 's/^/ /' "$actual_file" + echo " Diff:" + diff "$expected_file" "$actual_file" | sed 's/^/ /' || true + failed=$((failed + 1)) + fi +} + +echo "Running workflow sanitization tests..." +echo + +# Test 1: Multi-line on: block +assert_output "Multi-line on: block" \ + "$TEST_DIR/input-multiline.yml" \ + "$TEST_DIR/expected-multiline.yml" + +# Test 2: Single-line on: block +assert_output "Single-line on: block" \ + "$TEST_DIR/input-singleline.yml" \ + "$TEST_DIR/expected-singleline.yml" + +# Test 3: Complex on: block with nested structures +assert_output "Complex on: block" \ + "$TEST_DIR/input-complex.yml" \ + "$TEST_DIR/expected-complex.yml" + +# Test 4: on: block with comments +assert_output "on: block with comments" \ + "$TEST_DIR/input-comments.yml" \ + "$TEST_DIR/expected-comments.yml" + +echo +echo "Results: $passed passed, $failed failed" + +if [[ $failed -gt 0 ]]; then + exit 1 +fi From 6b7c7f1248880b7e765fab98a4ec2de8d910dd43 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Tue, 2 Dec 2025 16:15:59 -0500 Subject: [PATCH 2/2] ci: Use PAT for workflow file push permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Nori](https://nori.ai) Co-Authored-By: Nori --- .github/workflows/upstream-sync.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 23b30fbf2..7abd2ff76 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -37,7 +37,8 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + # PAT required to push workflow file changes (GITHUB_TOKEN lacks 'workflows' permission) + token: ${{ secrets.PAT_NORI_CLI_WORKFLOW_SYNC }} - name: Configure git run: | @@ -194,7 +195,7 @@ jobs: - name: Create draft PR if: steps.check.outputs.exists == 'false' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.PAT_NORI_CLI_WORKFLOW_SYNC }} TARGET_TAG: ${{ steps.tag.outputs.target_tag }} SYNC_BRANCH: ${{ steps.tag.outputs.sync_branch }} SANITIZED_FILES: ${{ steps.sync.outputs.sanitized_files }}