From aee352e793e2cc7bfe2bcdc084ae9b54be88659f Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 18 Jul 2025 14:51:34 +0200 Subject: [PATCH] feat: shared pre-job action --- .github/actions/pre-job/action.yml | 122 ++++++++++++++++++++ .github/actions/pre-job/check-conditions.js | 105 +++++++++++++++++ .github/actions/pre-job/generate-outputs.js | 97 ++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 .github/actions/pre-job/action.yml create mode 100644 .github/actions/pre-job/check-conditions.js create mode 100644 .github/actions/pre-job/generate-outputs.js diff --git a/.github/actions/pre-job/action.yml b/.github/actions/pre-job/action.yml new file mode 100644 index 0000000000..78b7fe9f07 --- /dev/null +++ b/.github/actions/pre-job/action.yml @@ -0,0 +1,122 @@ +name: 'Pre-Job' +description: 'Determines which jobs should run based on changed paths' +inputs: + filters: + description: 'Path filters as YAML string' + required: true + force-filters: + description: 'Additional path filters that trigger force-run (e.g., workflow files)' + required: false + default: '' + force-events: + description: 'Events that should force all jobs to run (comma-separated)' + required: false + default: 'workflow_dispatch' + force-branches: + description: 'Branches that should force all jobs to run (comma-separated)' + required: false + default: '' + exclude-branches: + description: 'Branches to exclude from running (comma-separated)' + required: false + default: '' + skip-force-logic: + description: 'Skip the standard force logic (for special cases like weblate)' + required: false + default: 'false' + +outputs: + # Individual outputs that can be accessed directly + should_run: + description: 'Nested object with filter results (access via fromJSON(steps.pre-job.outputs.should_run).filter_name)' + value: ${{ steps.generate-outputs.outputs.should_run }} + +runs: + using: 'composite' + steps: + - name: Convert filters to JSON + id: convert-filters + shell: python + run: | + import json + import os + import yaml + + # Get the filters input + filters_yaml = """${{ inputs.filters }}""" + + try: + # Parse YAML properly using the yaml library + filters_dict = yaml.safe_load(filters_yaml) + + if not isinstance(filters_dict, dict): + raise ValueError("Filters must be a YAML dictionary") + + if not filters_dict: + raise ValueError("No valid filters found") + + # We only need the filter names (keys), not the actual path arrays + filter_names = {name: [] for name in filters_dict.keys()} + + filters_json = json.dumps(filter_names) + + print("Converted filters to JSON:") + print(filters_json) + + # Set GitHub Actions output + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"filters_json={filters_json}\n") + + except Exception as e: + print(f"Error converting filters: {e}") + exit(1) + + - name: Check conditions and determine if path filtering is needed + id: check-conditions + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + force-events: ${{ inputs.force-events }} + force-branches: ${{ inputs.force-branches }} + exclude-branches: ${{ inputs.exclude-branches }} + skip-force-logic: ${{ inputs.skip-force-logic }} + filters-json: ${{ steps.convert-filters.outputs.filters_json }} + script: | + const script = require('./.github/actions/pre-job/check-conditions.js') + script({ core, context }) + + - name: Checkout code + if: ${{ steps.check-conditions.outputs.needs_path_filtering == 'true' }} + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Check force paths + if: ${{ steps.check-conditions.outputs.needs_path_filtering == 'true' && inputs.force-filters != '' }} + id: force_paths + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + filters: | + force-paths: + ${{ inputs.force-filters }} + + - name: Check main paths + if: ${{ steps.check-conditions.outputs.needs_path_filtering == 'true' && (inputs.force-filters == '' || steps.force_paths.outputs.force-paths != 'true') }} + id: main_paths + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + filters: ${{ inputs.filters }} + + - name: Generate final outputs + id: generate-outputs + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + filters-json: ${{ steps.convert-filters.outputs.filters_json }} + skip-force-logic: ${{ inputs.skip-force-logic }} + force-triggered: ${{ steps.check-conditions.outputs.force_triggered }} + should-skip: ${{ steps.check-conditions.outputs.should_skip }} + needs-path-filtering: ${{ steps.check-conditions.outputs.needs_path_filtering }} + force-path-results: ${{ toJSON(steps.force_paths.outputs) }} + main-path-results: ${{ toJSON(steps.main_paths.outputs) }} + script: | + const script = require('./.github/actions/pre-job/generate-outputs.js') + script({ core }) diff --git a/.github/actions/pre-job/check-conditions.js b/.github/actions/pre-job/check-conditions.js new file mode 100644 index 0000000000..75113c5c30 --- /dev/null +++ b/.github/actions/pre-job/check-conditions.js @@ -0,0 +1,105 @@ +module.exports = ({ core, context }) => { + console.log('=== Pre-Job: Checking Conditions ==='); + + // Get inputs directly from core + const forceEvents = core + .getInput('force-events') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const forceBranches = core + .getInput('force-branches') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const excludeBranches = core + .getInput('exclude-branches') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const skipForceLogic = core.getInput('skip-force-logic') === 'true'; + const filtersJson = core.getInput('filters-json'); + + // Parse JSON filters (much more reliable than YAML parsing) + let filterNames = []; + try { + const filters = JSON.parse(filtersJson); + filterNames = Object.keys(filters); + } catch (error) { + core.setFailed(`Failed to parse filters JSON: ${error.message}`); + return; + } + + // Get GitHub context + const currentEvent = context.eventName; + // Fix: Handle different ref types safely + const currentBranch = context.ref?.startsWith('refs/heads/') + ? context.ref.replace('refs/heads/', '') + : context.ref || ''; + const currentHeadRef = context.payload.pull_request?.head?.ref || ''; + + console.log('Context:', { + event: currentEvent, + branch: currentBranch, + headRef: currentHeadRef, + filterCount: filterNames.length, + }); + + console.log('Configuration:', { + forceEvents, + forceBranches, + excludeBranches, + skipForceLogic, + }); + + // Validate inputs + if (!filtersJson || !filtersJson.trim()) { + core.setFailed('filters-json input is required and cannot be empty'); + return; + } + + if (filterNames.length === 0) { + core.setFailed('No valid filters found in filters-json input'); + return; + } + + // Step 1: Check exclusion conditions (fastest short-circuit) + const shouldSkip = excludeBranches.some( + (branch) => currentHeadRef === branch, + ); + + if (shouldSkip) { + console.log(`🚫 EXCLUDED: Branch ${currentHeadRef} is in exclude list`); + core.setOutput('should_skip', true); + core.setOutput('force_triggered', false); + core.setOutput('needs_path_filtering', false); + return; + } + + // Step 2: Check force conditions (no checkout needed) + let forceTriggered = false; + if (!skipForceLogic) { + const eventForce = forceEvents.includes(currentEvent); + const branchForce = forceBranches.includes(currentBranch); + forceTriggered = eventForce || branchForce; + + if (forceTriggered) { + const reason = eventForce + ? `event: ${currentEvent}` + : `branch: ${currentBranch}`; + console.log(`🚀 FORCED: Triggered by ${reason}`); + core.setOutput('should_skip', false); + core.setOutput('force_triggered', true); + core.setOutput('needs_path_filtering', false); + return; + } + } + + // Step 3: Need to do path filtering + console.log( + '📁 PATH FILTERING: No force conditions met, need to check changed paths', + ); + core.setOutput('should_skip', false); + core.setOutput('force_triggered', false); + core.setOutput('needs_path_filtering', true); +}; diff --git a/.github/actions/pre-job/generate-outputs.js b/.github/actions/pre-job/generate-outputs.js new file mode 100644 index 0000000000..c2e137ecd0 --- /dev/null +++ b/.github/actions/pre-job/generate-outputs.js @@ -0,0 +1,97 @@ +// No longer need YAML parser - using JSON from Python conversion +module.exports = ({ core }) => { + console.log('=== Pre-Job: Generating Final Outputs ==='); + + try { + // Get inputs directly from core + const filtersJson = core.getInput('filters-json'); + const skipForceLogic = core.getInput('skip-force-logic') === 'true'; + + // Get step outputs + const forceTriggered = core.getInput('force-triggered') === 'true'; + const shouldSkip = core.getInput('should-skip') === 'true'; + const needsPathFiltering = core.getInput('needs-path-filtering') === 'true'; + + // Parse path results from separate steps + let forcePathResults = {}; + let mainPathResults = {}; + + try { + const forcePathRaw = core.getInput('force-path-results'); + if (forcePathRaw && forcePathRaw !== '{}') { + forcePathResults = JSON.parse(forcePathRaw); + } + } catch (e) { + console.log('No force path results or parse error:', e.message); + } + + try { + const mainPathRaw = core.getInput('main-path-results'); + if (mainPathRaw && mainPathRaw !== '{}') { + mainPathResults = JSON.parse(mainPathRaw); + } + } catch (e) { + console.log('No main path results or parse error:', e.message); + } + + // Parse JSON filters (much more reliable than YAML parsing) + let filterNames = []; + try { + const filters = JSON.parse(filtersJson); + filterNames = Object.keys(filters); + } catch (error) { + core.setFailed(`Failed to parse filters JSON: ${error.message}`); + return; + } + + console.log('Processing filters:', filterNames); + + const results = {}; + + // Handle early exit scenarios + if (shouldSkip) { + console.log('🚫 Generating SKIP results (all false)'); + for (const filterName of filterNames) { + results[filterName] = false; + } + } else if (forceTriggered && !skipForceLogic) { + console.log('🚀 Generating FORCE results (all true)'); + for (const filterName of filterNames) { + results[filterName] = true; + } + } else if (!needsPathFiltering) { + console.log('⚡ No path filtering needed, all false'); + for (const filterName of filterNames) { + results[filterName] = false; + } + } else { + console.log('📁 Generating PATH-BASED results'); + + // Check if force paths triggered (this forces ALL filters to true) + const forcePathsTriggered = forcePathResults['force-paths'] === 'true'; + + if (forcePathsTriggered && !skipForceLogic) { + console.log('🚀 FORCE-PATHS triggered - all filters true'); + for (const filterName of filterNames) { + results[filterName] = true; + } + } else { + console.log('📋 Using individual path results'); + // Process each filter based on main path results + for (const filterName of filterNames) { + const pathResult = mainPathResults[filterName] === 'true'; + results[filterName] = pathResult; + + console.log(`Filter ${filterName}: ${pathResult}`); + } + } + } + + // Output as JSON object that can be accessed with fromJSON() + core.setOutput('should_run', JSON.stringify(results)); + + console.log('✅ Final results:', results); + } catch (error) { + core.setFailed(`Failed to generate outputs: ${error.message}`); + } +};