diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
new file mode 100644
index 0000000..12ce746
--- /dev/null
+++ b/.github/actionlint.yaml
@@ -0,0 +1,3 @@
+self-hosted-runner:
+ labels:
+ - blacksmith-4vcpu-ubuntu-2404
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index b5e87ac..ecf30ca 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -103,12 +103,23 @@ updates:
- "minor"
- "patch"
+ # Linting and code quality actions
+ linting:
+ patterns:
+ - "ibiqlik/action-yamllint"
+ - "raven-actions/actionlint"
+ - "crate-ci/typos"
+ - "tcort/github-action-markdown-link-check"
+ update-types:
+ - "major"
+ - "minor"
+ - "patch"
+
# Miscellaneous third-party utilities
utilities:
patterns:
- "amannn/action-semantic-pull-request"
- "actions/labeler"
- - "tcort/github-action-markdown-link-check"
- "actions/github-script"
- "mikefarah/yq"
update-types:
diff --git a/.github/labels.yml b/.github/labels.yml
index 2983621..4a6a6cf 100644
--- a/.github/labels.yml
+++ b/.github/labels.yml
@@ -87,3 +87,7 @@
- name: notify
color: "fbca04"
description: Changes to notification composite actions (src/notify/)
+
+- name: lint
+ color: "7c3aed"
+ description: Changes to linting and code quality checks
diff --git a/.github/markdown-link-check-config.json b/.github/markdown-link-check-config.json
new file mode 100644
index 0000000..b70da11
--- /dev/null
+++ b/.github/markdown-link-check-config.json
@@ -0,0 +1,26 @@
+{
+ "ignorePatterns": [
+ {
+ "pattern": "^https://github\\.com/LerianStudio/github-actions-shared-workflows/actions/runs/"
+ },
+ {
+ "pattern": "^https://github\\.com/LerianStudio/github-actions-shared-workflows/pull/"
+ },
+ {
+ "pattern": "^https://github\\.com/<"
+ }
+ ],
+ "httpHeaders": [
+ {
+ "urls": ["https://github.com"],
+ "headers": {
+ "Accept-Encoding": "br, gzip, deflate"
+ }
+ }
+ ],
+ "timeout": "10s",
+ "retryOn429": true,
+ "retryCount": 3,
+ "fallbackRetryDelay": "5s",
+ "aliveStatusCodes": [200, 206, 301, 302, 307, 308]
+}
diff --git a/.github/workflows/self-pr-validation.yml b/.github/workflows/self-pr-validation.yml
new file mode 100644
index 0000000..c98093f
--- /dev/null
+++ b/.github/workflows/self-pr-validation.yml
@@ -0,0 +1,214 @@
+name: Self — PR Validation
+
+on:
+ pull_request:
+ branches:
+ - develop
+ - main
+ types:
+ - opened
+ - synchronize
+ - reopened
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ checks: read
+
+jobs:
+ # ----------------- PR Validation -----------------
+ validation:
+ if: github.event_name == 'pull_request'
+ permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+ 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"
+ secrets: inherit
+
+ # ----------------- Changed Files Detection -----------------
+ changed-files:
+ name: Detect Changed Files
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ permissions:
+ contents: read
+ pull-requests: read
+ outputs:
+ yaml_files: ${{ steps.detect.outputs.yaml-files }}
+ workflow_files: ${{ steps.detect.outputs.workflow-files }}
+ action_files: ${{ steps.detect.outputs.action-files }}
+ composite_files: ${{ steps.detect.outputs.composite-files }}
+ markdown_files: ${{ steps.detect.outputs.markdown-files }}
+ all_files: ${{ steps.detect.outputs.all-files }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Detect changed files
+ id: detect
+ uses: ./src/config/changed-workflows
+ with:
+ github-token: ${{ github.token }}
+
+ # ----------------- YAML Lint -----------------
+ yamllint:
+ name: YAML Lint
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ needs: changed-files
+ if: needs.changed-files.outputs.yaml_files != ''
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: YAML Lint
+ uses: ./src/lint/yamllint
+ with:
+ file-or-dir: ${{ needs.changed-files.outputs.yaml_files }}
+
+ # ----------------- Action Lint -----------------
+ actionlint:
+ name: Action Lint
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ needs: changed-files
+ if: needs.changed-files.outputs.workflow_files != ''
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Action Lint
+ uses: ./src/lint/actionlint
+ with:
+ files: ${{ needs.changed-files.outputs.workflow_files }}
+
+ # ----------------- Pinned Actions Check -----------------
+ pinned-actions:
+ name: Pinned Actions Check
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ needs: changed-files
+ if: needs.changed-files.outputs.action_files != ''
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Pinned Actions Check
+ uses: ./src/lint/pinned-actions
+ with:
+ files: ${{ needs.changed-files.outputs.action_files }}
+
+ # ----------------- Markdown Link Check -----------------
+ markdown-link-check:
+ name: Markdown Link Check
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ needs: changed-files
+ if: needs.changed-files.outputs.markdown_files != ''
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Markdown Link Check
+ uses: ./src/lint/markdown-link-check
+ with:
+ file-path: ${{ needs.changed-files.outputs.markdown_files }}
+
+ # ----------------- Spelling Check -----------------
+ typos:
+ name: Spelling Check
+ needs: changed-files
+ if: needs.changed-files.outputs.all_files != ''
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Spelling Check
+ uses: ./src/lint/typos
+ with:
+ files: ${{ needs.changed-files.outputs.all_files }}
+
+ # ----------------- Shell Check -----------------
+ shellcheck:
+ name: Shell Check
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ needs: changed-files
+ if: needs.changed-files.outputs.action_files != ''
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Shell Check
+ uses: ./src/lint/shellcheck
+ with:
+ files: ${{ needs.changed-files.outputs.action_files }}
+
+ # ----------------- README Check -----------------
+ readme-check:
+ name: README Check
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ needs: changed-files
+ if: needs.changed-files.outputs.action_files != ''
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: README Check
+ uses: ./src/lint/readme-check
+ with:
+ files: ${{ needs.changed-files.outputs.action_files }}
+
+ # ----------------- Composite Schema Lint -----------------
+ composite-schema:
+ name: Composite Schema Lint
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ needs: changed-files
+ if: needs.changed-files.outputs.composite_files != ''
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Composite Schema Lint
+ uses: ./src/lint/composite-schema
+ with:
+ files: ${{ needs.changed-files.outputs.composite_files }}
+
+ # ----------------- Lint Report -----------------
+ lint-report:
+ name: Lint Report
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ permissions:
+ actions: read
+ contents: read
+ pull-requests: write
+ issues: write
+ checks: read
+ needs: [changed-files, yamllint, actionlint, pinned-actions, markdown-link-check, typos, shellcheck, readme-check, composite-schema]
+ if: always() && github.event_name == 'pull_request' && needs.changed-files.result == 'success'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Post Lint Report
+ uses: ./src/notify/pr-lint-reporter
+ with:
+ github-token: ${{ secrets.MANAGE_TOKEN || github.token }}
+ yamllint-result: ${{ needs.yamllint.result }}
+ yamllint-files: ${{ needs.changed-files.outputs.yaml_files }}
+ actionlint-result: ${{ needs.actionlint.result }}
+ actionlint-files: ${{ needs.changed-files.outputs.workflow_files }}
+ pinned-actions-result: ${{ needs.pinned-actions.result }}
+ pinned-actions-files: ${{ needs.changed-files.outputs.action_files }}
+ markdown-result: ${{ needs.markdown-link-check.result }}
+ markdown-files: ${{ needs.changed-files.outputs.markdown_files }}
+ typos-result: ${{ needs.typos.result }}
+ typos-files: ${{ needs.changed-files.outputs.all_files }}
+ shellcheck-result: ${{ needs.shellcheck.result }}
+ shellcheck-files: ${{ needs.changed-files.outputs.action_files }}
+ readme-result: ${{ needs.readme-check.result }}
+ readme-files: ${{ needs.changed-files.outputs.action_files }}
+ composite-schema-result: ${{ needs.composite-schema.result }}
+ composite-schema-files: ${{ needs.changed-files.outputs.composite_files }}
diff --git a/.yamllint.yml b/.yamllint.yml
new file mode 100644
index 0000000..681d7d6
--- /dev/null
+++ b/.yamllint.yml
@@ -0,0 +1,20 @@
+---
+extends: default
+
+rules:
+ # GitHub Actions uses bare `on:` as top-level key — avoid truthy false positives
+ truthy:
+ allowed-values: ["true", "false"]
+ check-keys: false
+
+ # Workflow files have long run: blocks and action refs
+ line-length:
+ max: 200
+ level: warning
+
+ indentation:
+ spaces: 2
+ indent-sequences: whatever
+
+ # Not enforcing leading `---` — optional in workflow files
+ document-start: disable
diff --git a/src/config/changed-workflows/README.md b/src/config/changed-workflows/README.md
new file mode 100644
index 0000000..3a07a53
--- /dev/null
+++ b/src/config/changed-workflows/README.md
@@ -0,0 +1,52 @@
+
+
+  |
+ changed-workflows |
+
+
+
+Detect changed files in a pull request and categorize them by type for downstream lint jobs.
+
+## Outputs
+
+| Output | Format | Description |
+|--------|--------|-------------|
+| `yaml-files` | Space-separated | All changed `.yml` files |
+| `workflow-files` | Comma-separated | Changed `.github/workflows/*.yml` files |
+| `action-files` | Space-separated | Changed workflow and composite `.yml`/`.yaml` files |
+| `markdown-files` | Comma-separated | Changed `.md` files |
+
+On `workflow_dispatch`, falls back to scanning the full repository.
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `github-token` | GitHub token for `gh` CLI access | No | `""` |
+
+## Usage as composite step
+
+```yaml
+- name: Checkout
+ uses: actions/checkout@v4
+
+- name: Detect changed files
+ id: changed
+ uses: LerianStudio/github-actions-shared-workflows/src/config/changed-workflows@v1.2.3
+ with:
+ github-token: ${{ github.token }}
+
+- name: YAML Lint
+ if: steps.changed.outputs.yaml-files != ''
+ uses: LerianStudio/github-actions-shared-workflows/src/lint/yamllint@v1.2.3
+ with:
+ file-or-dir: ${{ steps.changed.outputs.yaml-files }}
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ contents: read
+ pull-requests: read
+```
diff --git a/src/config/changed-workflows/action.yml b/src/config/changed-workflows/action.yml
new file mode 100644
index 0000000..8a637f8
--- /dev/null
+++ b/src/config/changed-workflows/action.yml
@@ -0,0 +1,105 @@
+name: Detect Changed Workflow Files
+description: Categorize changed files in a PR by type (YAML, workflows, actions, markdown) for lint jobs.
+
+inputs:
+ github-token:
+ description: GitHub token for gh CLI access
+ required: true
+
+outputs:
+ yaml-files:
+ description: Space-separated list of changed .yml files
+ value: ${{ steps.detect.outputs.yaml_files }}
+ workflow-files:
+ description: Comma-separated list of changed .github/workflows/*.yml files
+ value: ${{ steps.detect.outputs.workflow_files }}
+ action-files:
+ description: Comma-separated list of changed workflow and composite .yml/.yaml files
+ value: ${{ steps.detect.outputs.action_files }}
+ composite-files:
+ description: Comma-separated list of changed composite action.yml files under src/
+ value: ${{ steps.detect.outputs.composite_files }}
+ markdown-files:
+ description: Comma-separated list of changed .md files
+ value: ${{ steps.detect.outputs.markdown_files }}
+ all-files:
+ description: Space-separated list of all changed files
+ value: ${{ steps.detect.outputs.all_files }}
+
+runs:
+ using: composite
+ steps:
+ - name: Detect changed files
+ id: detect
+ shell: bash
+ run: |
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ changed=$(gh pr diff "${{ github.event.pull_request.number }}" --name-only)
+
+ yaml_files=$(echo "$changed" | grep -E '\.yml$' | tr '\n' ' ' | sed 's/ $//' || true)
+ workflow_files=$(echo "$changed" | grep -E '^\.github/workflows/.*\.yml$' | tr '\n' ',' | sed 's/,$//' || true)
+ action_files=$(echo "$changed" | grep -E '\.(yml|yaml)$' | grep -E '(\.github/workflows/|src/)' | tr '\n' ',' | sed 's/,$//' || true)
+ composite_files=$(echo "$changed" | grep -E '^src/.*/action\.(yml|yaml)$' | tr '\n' ',' | sed 's/,$//' || true)
+ markdown_files=$(echo "$changed" | grep -E '\.md$' | tr '\n' ',' | sed 's/,$//' || true)
+ all_files=$(echo "$changed" | tr '\n' ' ' | sed 's/ $//' || true)
+ else
+ yaml_files="."
+ workflow_files=".github/workflows/*.yml"
+ action_files=$(find .github/workflows/ src/ \( -name '*.yml' -o -name '*.yaml' \) 2>/dev/null | tr '\n' ',' | sed 's/,$//')
+ composite_files=$(find src/ \( -name 'action.yml' -o -name 'action.yaml' \) 2>/dev/null | tr '\n' ',' | sed 's/,$//')
+ markdown_files=""
+ all_files="."
+ fi
+
+ echo "yaml_files=${yaml_files}" >> "$GITHUB_OUTPUT"
+ echo "workflow_files=${workflow_files}" >> "$GITHUB_OUTPUT"
+ echo "action_files=${action_files}" >> "$GITHUB_OUTPUT"
+ echo "composite_files=${composite_files}" >> "$GITHUB_OUTPUT"
+ echo "markdown_files=${markdown_files}" >> "$GITHUB_OUTPUT"
+ echo "all_files=${all_files}" >> "$GITHUB_OUTPUT"
+
+ log_files() {
+ local label="$1"
+ local files="$2"
+ local sep="$3"
+ if [ -n "$files" ]; then
+ count=$(echo "$files" | tr "${sep}" '\n' | sed '/^$/d' | wc -l | tr -d ' ')
+ echo "::group::${label} (${count})"
+ echo "$files" | tr "${sep}" '\n' | sed '/^$/d' | sed 's/^/ - /'
+ echo "::endgroup::"
+ fi
+ }
+
+ log_files "YAML files" "${yaml_files}" ' '
+ log_files "Workflow files" "${workflow_files}" ','
+ log_files "Action files" "${action_files}" ','
+ log_files "Composite files" "${composite_files}" ','
+ log_files "Markdown files" "${markdown_files}" ','
+ log_files "All files" "${all_files}" ' '
+
+ {
+ echo "## Changed Files Detected"
+ echo ""
+
+ print_files() {
+ local label="$1"
+ local files="$2"
+ local sep="$3"
+ echo "### ${label}"
+ if [ -z "$files" ]; then
+ echo "_No changes_"
+ else
+ echo "$files" | tr "${sep}" '\n' | sed '/^$/d' | sed 's/.*/- `&`/'
+ fi
+ echo ""
+ }
+
+ print_files "YAML files" "${yaml_files}" ' '
+ print_files "Workflow files" "${workflow_files}" ','
+ print_files "Action files" "${action_files}" ','
+ print_files "Composite files" "${composite_files}" ','
+ print_files "Markdown files" "${markdown_files}" ','
+ print_files "All files" "${all_files}" ' '
+ } >> "$GITHUB_STEP_SUMMARY"
+ env:
+ GH_TOKEN: ${{ inputs.github-token }}
diff --git a/src/lint/actionlint/README.md b/src/lint/actionlint/README.md
new file mode 100644
index 0000000..beac24b
--- /dev/null
+++ b/src/lint/actionlint/README.md
@@ -0,0 +1,44 @@
+
+
+  |
+ actionlint |
+
+
+
+Validate GitHub Actions workflow syntax using [actionlint](https://github.com/rhysd/actionlint).
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `files` | Comma-separated glob patterns of workflow files to lint | No | `.github/workflows/*.yml` |
+| `shellcheck` | Enable shellcheck integration for `run:` blocks | No | `true` |
+| `fail-on-error` | Fail the step on lint errors | No | `true` |
+
+## Usage as composite step
+
+```yaml
+- name: Checkout
+ uses: actions/checkout@v4
+
+- name: Action Lint
+ uses: LerianStudio/github-actions-shared-workflows/src/lint/actionlint@v1.2.3
+ with:
+ files: ".github/workflows/*.yml"
+```
+
+## Usage via reusable workflow
+
+```yaml
+jobs:
+ lint:
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-lint.yml@v1.2.3
+ secrets: inherit
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ contents: read
+```
diff --git a/src/lint/actionlint/action.yml b/src/lint/actionlint/action.yml
new file mode 100644
index 0000000..9caa866
--- /dev/null
+++ b/src/lint/actionlint/action.yml
@@ -0,0 +1,39 @@
+name: Action Lint
+description: Validate GitHub Actions workflow syntax using actionlint.
+
+inputs:
+ files:
+ description: Comma-separated glob patterns of workflow files to lint (empty = skip)
+ required: false
+ default: ".github/workflows/*.yml"
+ shellcheck:
+ description: Enable shellcheck integration for run blocks
+ required: false
+ default: "true"
+ fail-on-error:
+ description: Fail the step on lint errors
+ required: false
+ default: "true"
+
+runs:
+ using: composite
+ steps:
+ - name: Log files
+ if: inputs.files != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.files }}
+ run: |
+ files=$(printf '%s\n' "$FILES" | tr ',' '\n' | sed '/^$/d')
+ count=$(echo "$files" | wc -l | tr -d ' ')
+ echo "::group::Files analyzed by actionlint (${count})"
+ echo "$files" | sed 's/^/ - /'
+ echo "::endgroup::"
+
+ - name: Run actionlint
+ if: inputs.files != ''
+ uses: raven-actions/actionlint@v2.1.2
+ with:
+ files: ${{ inputs.files }}
+ shellcheck: ${{ inputs.shellcheck }}
+ fail-on-error: ${{ inputs.fail-on-error }}
diff --git a/src/lint/composite-schema/README.md b/src/lint/composite-schema/README.md
new file mode 100644
index 0000000..1095b46
--- /dev/null
+++ b/src/lint/composite-schema/README.md
@@ -0,0 +1,57 @@
+
+
+  |
+ composite-schema |
+
+
+
+Validate that composite actions under `src/` follow project conventions. Checks performed:
+
+**Directory structure**
+- Must be exactly `src///action.yml` (no shallower, no deeper)
+
+**Root level**
+- `name` field is present and non-empty
+- `description` field is present and non-empty
+
+**Steps**
+- `runs.steps` is defined and non-empty
+- Step count does not exceed 15 (split into smaller composites if so)
+
+**Inputs**
+- Every input has a non-empty `description`
+- `required: true` inputs must **not** have a `default`
+- `required: false` inputs **must** have a `default`
+- Input names must be **kebab-case** (e.g. `github-token`, not `githubToken` or `github_token`)
+- Input names must not use reserved prefixes: `GITHUB_*`, `ACTIONS_*`, `RUNNER_*`
+
+Only files whose `runs.using` is `composite` are validated; all others are silently skipped.
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `files` | Comma-separated list of YAML files to check (empty = skip) | No | `` |
+
+## Usage as composite step
+
+```yaml
+jobs:
+ composite-schema:
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Composite Schema Lint
+ uses: LerianStudio/github-actions-shared-workflows/src/lint/composite-schema@develop
+ with:
+ files: "src/lint/my-check/action.yml,src/build/my-build/action.yml"
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ contents: read
+```
diff --git a/src/lint/composite-schema/action.yml b/src/lint/composite-schema/action.yml
new file mode 100644
index 0000000..0815e5c
--- /dev/null
+++ b/src/lint/composite-schema/action.yml
@@ -0,0 +1,140 @@
+name: Composite Schema Lint
+description: Validate composite actions follow project conventions (inputs, steps, naming, reserved prefixes).
+
+inputs:
+ files:
+ description: Comma-separated list of YAML files to check (empty = skip)
+ required: false
+ default: ""
+
+runs:
+ using: composite
+ steps:
+ # ----------------- Setup -----------------
+ - name: Install dependencies
+ if: inputs.files != ''
+ shell: bash
+ run: |
+ if ! python3 -c "import yaml" 2>/dev/null; then
+ sudo apt-get install -y --no-install-recommends python3-yaml
+ fi
+
+ # ----------------- Log -----------------
+ - name: Log files
+ if: inputs.files != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.files }}
+ run: |
+ files=$(printf '%s\n' "$FILES" | tr ',' '\n' | sed '/^$/d')
+ count=$(echo "$files" | wc -l | tr -d ' ')
+ echo "::group::Files analyzed by composite-schema (${count})"
+ echo "$files" | sed 's/^/ - /'
+ echo "::endgroup::"
+
+ # ----------------- Check -----------------
+ - name: Validate composite conventions
+ if: inputs.files != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.files }}
+ run: |
+ python3 - <<'PYEOF'
+ import os, re, sys, yaml
+
+ RESERVED_PREFIXES = ('GITHUB_', 'ACTIONS_', 'RUNNER_')
+ KEBAB_RE = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$')
+ MAX_STEPS = 15
+
+ files = os.environ.get('FILES', '').split(',')
+ violations = 0
+
+ def err(filepath, msg):
+ global violations
+ print(f'::error file={filepath}::{msg}')
+ violations += 1
+
+ for filepath in files:
+ filepath = filepath.strip()
+ if not filepath or not os.path.isfile(filepath):
+ continue
+
+ try:
+ with open(filepath) as f:
+ data = yaml.safe_load(f)
+ except Exception as e:
+ err(filepath, f'Could not parse YAML: {e}')
+ continue
+
+ if not isinstance(data, dict):
+ err(filepath, 'Action metadata must be a YAML mapping.')
+ continue
+
+ runs = data.get('runs')
+ if not isinstance(runs, dict):
+ err(filepath, '"runs" must be a mapping.')
+ continue
+ if runs.get('using') != 'composite':
+ continue
+
+ # ── Directory structure: must be src///action.yml ──
+ parts = filepath.replace('\\', '/').split('/')
+ if len(parts) != 4 or parts[0] != 'src' or parts[-1] != 'action.yml':
+ err(filepath, f'Composite must live at src///action.yml — got "{filepath}".')
+
+ # ── Root-level name and description ──
+ name_val = data.get('name')
+ if not isinstance(name_val, str) or not name_val.strip():
+ err(filepath, 'Missing top-level "name" field.')
+ desc_val = data.get('description')
+ if not isinstance(desc_val, str) or not desc_val.strip():
+ err(filepath, 'Missing top-level "description" field.')
+
+ # ── Steps must exist and be non-empty ──
+ steps = runs.get('steps')
+ if not isinstance(steps, list):
+ err(filepath, '"runs.steps" must be an array.')
+ elif not steps:
+ err(filepath, 'Composite has no steps defined under runs.steps.')
+ elif len(steps) > MAX_STEPS:
+ err(filepath, f'Too many steps ({len(steps)}); maximum allowed is {MAX_STEPS}. Consider splitting into smaller composites.')
+
+ # ── Input conventions ──
+ inputs = data.get('inputs')
+ if inputs is None:
+ inputs = {}
+ elif not isinstance(inputs, dict):
+ err(filepath, '"inputs" must be a mapping.')
+ continue
+
+ for name, spec in inputs.items():
+ if not isinstance(spec, dict):
+ err(filepath, f'Input "{name}" definition must be a mapping.')
+ continue
+
+ description = spec.get('description')
+ has_desc = isinstance(description, str) and description.strip() != ''
+ required = spec.get('required', False) is True
+ has_default = 'default' in spec
+
+ if not has_desc:
+ err(filepath, f'Input "{name}" is missing a description.')
+
+ if required and has_default:
+ err(filepath, f'Input "{name}" is required: true but also defines a default (remove the default).')
+
+ if not required and not has_default:
+ err(filepath, f'Input "{name}" is required: false but has no default (add one).')
+
+ if any(name.upper().startswith(p) for p in RESERVED_PREFIXES):
+ err(filepath, f'Input "{name}" uses a reserved prefix ({", ".join(RESERVED_PREFIXES)}); rename to avoid runtime conflicts.')
+
+ if not KEBAB_RE.match(name):
+ err(filepath, f'Input "{name}" must be kebab-case (e.g. "github-token", "my-input").')
+
+ if violations > 0:
+ print(f'::error::Found {violations} composite schema violation(s).')
+ sys.exit(1)
+
+ print('All composite actions passed schema validation.')
+ PYEOF
diff --git a/src/lint/markdown-link-check/README.md b/src/lint/markdown-link-check/README.md
new file mode 100644
index 0000000..ffc426c
--- /dev/null
+++ b/src/lint/markdown-link-check/README.md
@@ -0,0 +1,44 @@
+
+
+  |
+ markdown-link-check |
+
+
+
+Validate that links in markdown files are not broken using [markdown-link-check](https://github.com/tcort/markdown-link-check).
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `file-path` | Comma-separated list of markdown files to check | No | `` |
+| `config-file` | Path to the markdown-link-check configuration file | No | `.github/markdown-link-check-config.json` |
+
+## Usage as composite step
+
+```yaml
+- name: Checkout
+ uses: actions/checkout@v4
+
+- name: Markdown Link Check
+ uses: LerianStudio/github-actions-shared-workflows/src/lint/markdown-link-check@v1.2.3
+ with:
+ file-path: "README.md,docs/go-ci.md"
+ config-file: ".github/markdown-link-check-config.json"
+```
+
+## Usage via reusable workflow
+
+```yaml
+jobs:
+ lint:
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-lint.yml@v1.2.3
+ secrets: inherit
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ contents: read
+```
diff --git a/src/lint/markdown-link-check/action.yml b/src/lint/markdown-link-check/action.yml
new file mode 100644
index 0000000..64e5101
--- /dev/null
+++ b/src/lint/markdown-link-check/action.yml
@@ -0,0 +1,34 @@
+name: Markdown Link Check
+description: Validate links in markdown files are not broken.
+
+inputs:
+ file-path:
+ description: Comma-separated list of markdown files to check (empty = skip)
+ required: false
+ default: ""
+ config-file:
+ description: Path to the markdown-link-check configuration file
+ required: false
+ default: ".github/markdown-link-check-config.json"
+
+runs:
+ using: composite
+ steps:
+ - name: Log files
+ if: inputs.file-path != ''
+ shell: bash
+ env:
+ FILE_PATH: ${{ inputs.file-path }}
+ run: |
+ files=$(printf '%s\n' "$FILE_PATH" | tr ',' '\n' | sed '/^$/d')
+ count=$(echo "$files" | wc -l | tr -d ' ')
+ echo "::group::Files analyzed by markdown-link-check (${count})"
+ echo "$files" | sed 's/^/ - /'
+ echo "::endgroup::"
+
+ - name: Run markdown link check
+ if: inputs.file-path != ''
+ uses: tcort/github-action-markdown-link-check@v1.1.2
+ with:
+ file-path: ${{ inputs.file-path }}
+ config-file: ${{ inputs.config-file }}
diff --git a/src/lint/pinned-actions/README.md b/src/lint/pinned-actions/README.md
new file mode 100644
index 0000000..02578de
--- /dev/null
+++ b/src/lint/pinned-actions/README.md
@@ -0,0 +1,44 @@
+
+
+  |
+ pinned-actions |
+
+
+
+Ensure all third-party GitHub Action references use pinned versions (`@vX.Y.Z` or `@sha`), not mutable refs like `@main` or `@master`.
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `files` | Space-separated list of workflow/composite files to check | No | `` |
+| `ignore-patterns` | Pipe-separated org/owner prefixes to skip (e.g. internal actions) | No | `LerianStudio/` |
+
+## Usage as composite step
+
+```yaml
+- name: Checkout
+ uses: actions/checkout@v4
+
+- name: Pinned Actions Check
+ uses: LerianStudio/github-actions-shared-workflows/src/lint/pinned-actions@v1.2.3
+ with:
+ files: ".github/workflows/ci.yml .github/workflows/deploy.yml"
+ ignore-patterns: "LerianStudio/|my-org/"
+```
+
+## Usage via reusable workflow
+
+```yaml
+jobs:
+ lint:
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-lint.yml@v1.2.3
+ secrets: inherit
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ contents: read
+```
diff --git a/src/lint/pinned-actions/action.yml b/src/lint/pinned-actions/action.yml
new file mode 100644
index 0000000..1d11075
--- /dev/null
+++ b/src/lint/pinned-actions/action.yml
@@ -0,0 +1,84 @@
+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.
+
+inputs:
+ files:
+ description: Comma-separated list of workflow/composite files to check (empty = skip)
+ 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)
+ required: false
+ default: "LerianStudio/"
+
+runs:
+ using: composite
+ steps:
+ - name: Log files
+ if: inputs.files != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.files }}
+ run: |
+ files=$(printf '%s\n' "$FILES" | tr ',' '\n' | sed '/^$/d')
+ count=$(echo "$files" | wc -l | tr -d ' ')
+ echo "::group::Files analyzed by pinned-actions check (${count})"
+ echo "$files" | sed 's/^/ - /'
+ echo "::endgroup::"
+
+ - name: Check for unpinned actions
+ if: inputs.files != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.files }}
+ WARN_PATTERNS_INPUT: ${{ inputs.warn-patterns }}
+ run: |
+ IFS='|' read -ra WARN_PATTERNS <<< "$WARN_PATTERNS_INPUT"
+ violations=0
+ warnings=0
+
+ for file in $(printf '%s\n' "$FILES" | tr ',' ' '); do
+ [ -f "$file" ] || continue
+ while IFS= read -r line; do
+ line_num=$(echo "$line" | cut -d: -f1)
+ content=$(echo "$line" | cut -d: -f2-)
+
+ # Strip inline YAML comments, then extract the ref (part after the last @)
+ normalized=$(printf '%s\n' "$content" | sed 's/[[:space:]]*#.*$//' | xargs)
+ ref=${normalized##*@}
+
+ # Check if this is an internal org (warn-only)
+ is_internal=false
+ for pattern in "${WARN_PATTERNS[@]}"; do
+ if printf '%s\n' "$normalized" | grep -Fq "$pattern"; then
+ is_internal=true
+ break
+ fi
+ 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
+ continue
+ fi
+ echo "::warning file=${file},line=${line_num}::Internal action not pinned to a final release 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
+ continue
+ fi
+ echo "::error file=${file},line=${line_num}::Unpinned action found: $normalized"
+ 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."
+ 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)."
+ exit 1
+ fi
+ echo "All external actions are properly pinned."
diff --git a/src/lint/readme-check/README.md b/src/lint/readme-check/README.md
new file mode 100644
index 0000000..466c02d
--- /dev/null
+++ b/src/lint/readme-check/README.md
@@ -0,0 +1,37 @@
+
+
+  |
+ readme-check |
+
+
+
+Ensure every composite action under `src/` has a sibling `README.md` file.
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `files` | Comma-separated list of changed files to check | No | `` |
+
+## Usage as composite step
+
+```yaml
+jobs:
+ readme-check:
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: README Check
+ uses: LerianStudio/github-actions-shared-workflows/src/lint/readme-check@develop
+ with:
+ files: "src/lint/my-check/action.yml,src/build/my-build/action.yml"
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ contents: read
+```
diff --git a/src/lint/readme-check/action.yml b/src/lint/readme-check/action.yml
new file mode 100644
index 0000000..bf81306
--- /dev/null
+++ b/src/lint/readme-check/action.yml
@@ -0,0 +1,59 @@
+name: README Check
+description: Ensure every composite action in src/ has a sibling README.md file.
+
+inputs:
+ files:
+ description: Comma-separated list of changed files to check (empty = skip)
+ required: false
+ default: ""
+
+runs:
+ using: composite
+ steps:
+ # ----------------- Log -----------------
+ - name: Log files
+ if: inputs.files != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.files }}
+ run: |
+ files=$(printf '%s\n' "$FILES" | tr ',' '\n' | grep 'src/.*action\.yml$' | sed '/^$/d' || true)
+ if [ -z "$files" ]; then
+ echo "No composite action.yml files in changeset — skipping."
+ exit 0
+ fi
+ count=$(echo "$files" | wc -l | tr -d ' ')
+ echo "::group::Composite action files checked for README (${count})"
+ echo "$files" | sed 's/^/ - /'
+ echo "::endgroup::"
+
+ # ----------------- Check -----------------
+ - name: Check for missing README.md
+ if: inputs.files != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.files }}
+ run: |
+ violations=0
+
+ for file in $(printf '%s\n' "$FILES" | tr ',' ' '); do
+ file=$(printf '%s' "$file" | xargs)
+ [ -f "$file" ] || continue
+
+ case "$file" in
+ src/*/action.yml)
+ dir=$(dirname "$file")
+ if [ ! -f "$dir/README.md" ]; then
+ echo "::error file=$file::Missing README.md in $dir — every composite action must have a README.md"
+ violations=$((violations + 1))
+ fi
+ ;;
+ esac
+ done
+
+ if [ "$violations" -gt 0 ]; then
+ echo "::error::Found $violations composite action(s) missing README.md."
+ exit 1
+ fi
+
+ echo "All composite actions have README.md."
diff --git a/src/lint/shellcheck/README.md b/src/lint/shellcheck/README.md
new file mode 100644
index 0000000..5f669b4
--- /dev/null
+++ b/src/lint/shellcheck/README.md
@@ -0,0 +1,38 @@
+
+
+  |
+ shellcheck |
+
+
+
+Run [shellcheck](https://github.com/koalaman/shellcheck) on all `run:` blocks embedded in GitHub Actions composite and workflow YAML files.
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `files` | Comma-separated list of YAML files to check | No | `` |
+| `severity` | Minimum severity to report and fail on (`error`, `warning`, `info`, `style`) | No | `warning` |
+
+## Usage as composite step
+
+```yaml
+jobs:
+ shellcheck:
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Shell Check
+ uses: LerianStudio/github-actions-shared-workflows/src/lint/shellcheck@develop
+ with:
+ files: ".github/workflows/ci.yml,src/lint/my-check/action.yml"
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ contents: read
+```
diff --git a/src/lint/shellcheck/action.yml b/src/lint/shellcheck/action.yml
new file mode 100644
index 0000000..d20b65a
--- /dev/null
+++ b/src/lint/shellcheck/action.yml
@@ -0,0 +1,137 @@
+name: Shell Check
+description: "Run shellcheck on shell run: blocks embedded in GitHub Actions composite and workflow YAML files."
+
+inputs:
+ files:
+ description: Comma-separated list of YAML files to check (empty = skip)
+ required: false
+ default: ""
+ severity:
+ description: Minimum shellcheck severity to report and fail on (error, warning, info, style)
+ required: false
+ default: "warning"
+
+runs:
+ using: composite
+ steps:
+ # ----------------- Setup -----------------
+ - name: Install dependencies
+ if: inputs.files != ''
+ shell: bash
+ run: |
+ if ! command -v shellcheck &>/dev/null; then
+ sudo apt-get install -y --no-install-recommends shellcheck
+ fi
+ if ! python3 -c "import yaml" 2>/dev/null; then
+ sudo apt-get install -y --no-install-recommends python3-yaml
+ fi
+
+ # ----------------- Log -----------------
+ - name: Log files
+ if: inputs.files != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.files }}
+ run: |
+ files=$(printf '%s\n' "$FILES" | tr ',' '\n' | sed '/^$/d')
+ count=$(echo "$files" | wc -l | tr -d ' ')
+ echo "::group::Files analyzed by shellcheck (${count})"
+ echo "$files" | sed 's/^/ - /'
+ echo "::endgroup::"
+
+ # ----------------- Check -----------------
+ - name: "Run shellcheck on run: blocks"
+ if: inputs.files != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.files }}
+ SEVERITY: ${{ inputs.severity }}
+ run: |
+ python3 - <<'PYEOF'
+ import os, re, sys, json, yaml, tempfile, subprocess
+
+ files = os.environ.get('FILES', '').split(',')
+ severity = os.environ.get('SEVERITY', 'warning')
+ violations = 0
+
+ def replace_gha_exprs(text):
+ # Replace GHA expression syntax with a shell-safe placeholder to avoid false positives
+ return re.sub(r'\$\{\{.*?\}\}', '${GHA_PLACEHOLDER}', text, flags=re.DOTALL)
+
+ for filepath in files:
+ filepath = filepath.strip()
+ if not filepath or not os.path.isfile(filepath):
+ continue
+
+ try:
+ with open(filepath) as f:
+ data = yaml.safe_load(f)
+ except Exception as e:
+ print(f'::warning file={filepath}::Could not parse YAML: {e}')
+ continue
+
+ if not isinstance(data, dict):
+ continue
+
+ steps = []
+ runs = data.get('runs') or {}
+ steps.extend(runs.get('steps') or [])
+ for job in (data.get('jobs') or {}).values():
+ if isinstance(job, dict):
+ steps.extend(job.get('steps') or [])
+
+ for step in steps:
+ if not isinstance(step, dict):
+ continue
+ run_block = step.get('run')
+ if not run_block:
+ continue
+ shell = step.get('shell', 'bash')
+ if shell not in ('bash', 'sh'):
+ continue
+
+ step_name = step.get('name', 'unnamed')
+ script = f'#!/usr/bin/env {shell}\n' + replace_gha_exprs(run_block)
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) as tmp:
+ tmp.write(script)
+ tmp_path = tmp.name
+
+ try:
+ result = subprocess.run(
+ [
+ 'shellcheck',
+ f'--shell={shell}',
+ f'--severity={severity}',
+ '--exclude=SC1090,SC1091,SC2154',
+ '--format=json',
+ tmp_path,
+ ],
+ capture_output=True,
+ text=True,
+ )
+
+ if result.returncode != 0:
+ if result.stdout:
+ issues = json.loads(result.stdout)
+ for issue in issues:
+ level = issue.get('level', 'warning')
+ msg = issue.get('message', '')
+ code = issue.get('code', '')
+ line = max(1, issue.get('line', 1) - 1)
+ ann = 'error' if level == 'error' else 'warning'
+ print(f'::{ann} file={filepath}::Step "{step_name}" (script line {line}): [SC{code}] {msg}')
+ violations += 1
+ else:
+ err = result.stderr.strip() or 'unknown error'
+ print(f'::error file={filepath}::Step "{step_name}" shellcheck failed: {err}')
+ violations += 1
+ finally:
+ os.unlink(tmp_path)
+
+ if violations > 0:
+ print(f'::error::Found {violations} shellcheck error(s) in run: blocks.')
+ sys.exit(1)
+
+ print('All shell run: blocks passed shellcheck.')
+ PYEOF
diff --git a/src/lint/typos/README.md b/src/lint/typos/README.md
new file mode 100644
index 0000000..b75e900
--- /dev/null
+++ b/src/lint/typos/README.md
@@ -0,0 +1,40 @@
+
+
+  |
+ typos |
+
+
+
+Detect typos in source code and documentation using [typos-cli](https://github.com/crate-ci/typos).
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `config` | Path to a typos configuration file (`_typos.toml`) | No | `` |
+
+## Usage as composite step
+
+```yaml
+- name: Checkout
+ uses: actions/checkout@v4
+
+- name: Spelling Check
+ uses: LerianStudio/github-actions-shared-workflows/src/lint/typos@v1.2.3
+```
+
+## Usage via reusable workflow
+
+```yaml
+jobs:
+ lint:
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-lint.yml@v1.2.3
+ secrets: inherit
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ contents: read
+```
diff --git a/src/lint/typos/action.yml b/src/lint/typos/action.yml
new file mode 100644
index 0000000..fadabed
--- /dev/null
+++ b/src/lint/typos/action.yml
@@ -0,0 +1,46 @@
+name: Spelling Check
+description: Detect typos in source code and documentation using typos-cli.
+
+inputs:
+ config:
+ description: Path to a typos configuration file (_typos.toml)
+ required: false
+ default: ""
+ files:
+ description: Space-separated list of files to check (empty = entire repository)
+ required: false
+ default: ""
+
+runs:
+ using: composite
+ steps:
+ - name: Summary
+ shell: bash
+ run: |
+ {
+ echo "## Spelling Check"
+ echo ""
+ if [ -z "${{ inputs.files }}" ]; then
+ echo "Scanning entire repository for typos."
+ else
+ count=$(echo "${{ inputs.files }}" | tr ' ' '\n' | sed '/^$/d' | wc -l | tr -d ' ')
+ echo "Scanning ${count} changed file(s) for typos."
+ fi
+ echo ""
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Log files
+ if: inputs.files != ''
+ shell: bash
+ run: |
+ files=$(echo "${{ inputs.files }}" | tr ' ' '\n' | sed '/^$/d')
+ count=$(echo "$files" | wc -l | tr -d ' ')
+ echo "::group::Files analyzed by typos (${count})"
+ echo "$files" | sed 's/^/ - /'
+ echo "::endgroup::"
+
+ - name: Run typos
+ uses: crate-ci/typos@v1.44.0
+ with:
+ files: ${{ inputs.files != '' && inputs.files || '.' }}
+ config: ${{ inputs.config }}
diff --git a/src/lint/yamllint/README.md b/src/lint/yamllint/README.md
new file mode 100644
index 0000000..3400d80
--- /dev/null
+++ b/src/lint/yamllint/README.md
@@ -0,0 +1,45 @@
+
+
+  |
+ yamllint |
+
+
+
+Validate YAML files using [yamllint](https://github.com/adrienverge/yamllint).
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `config-file` | Path to the yamllint configuration file | No | `.yamllint.yml` |
+| `file-or-dir` | Space-separated list of files or directories to lint | No | `.` |
+| `strict` | Treat warnings as errors | No | `false` |
+
+## Usage as composite step
+
+```yaml
+- name: Checkout
+ uses: actions/checkout@v4
+
+- name: YAML Lint
+ uses: LerianStudio/github-actions-shared-workflows/src/lint/yamllint@v1.2.3
+ with:
+ file-or-dir: ".github/workflows/ src/"
+ config-file: ".yamllint.yml"
+```
+
+## Usage via reusable workflow
+
+```yaml
+jobs:
+ lint:
+ uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-lint.yml@v1.2.3
+ secrets: inherit
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ contents: read
+```
diff --git a/src/lint/yamllint/action.yml b/src/lint/yamllint/action.yml
new file mode 100644
index 0000000..a6d14c4
--- /dev/null
+++ b/src/lint/yamllint/action.yml
@@ -0,0 +1,39 @@
+name: YAML Lint
+description: Validate YAML files using yamllint with configurable scope.
+
+inputs:
+ config-file:
+ description: Path to the yamllint configuration file
+ required: false
+ default: ".yamllint.yml"
+ file-or-dir:
+ description: Space-separated list of files or directories to lint (empty = skip)
+ required: false
+ default: "."
+ strict:
+ description: Treat warnings as errors
+ required: false
+ default: "false"
+
+runs:
+ using: composite
+ steps:
+ - name: Log files
+ if: inputs.file-or-dir != ''
+ shell: bash
+ env:
+ FILES: ${{ inputs.file-or-dir }}
+ run: |
+ files=$(printf '%s\n' "$FILES" | tr ' ' '\n' | sed '/^$/d')
+ count=$(echo "$files" | wc -l | tr -d ' ')
+ echo "::group::Files analyzed by yamllint (${count})"
+ echo "$files" | sed 's/^/ - /'
+ echo "::endgroup::"
+
+ - name: Run yamllint
+ if: inputs.file-or-dir != ''
+ uses: ibiqlik/action-yamllint@v3.1.1
+ with:
+ file_or_dir: ${{ inputs.file-or-dir }}
+ config_file: ${{ inputs.config-file }}
+ strict: ${{ inputs.strict }}
diff --git a/src/notify/pr-lint-reporter/README.md b/src/notify/pr-lint-reporter/README.md
new file mode 100644
index 0000000..25d8fc3
--- /dev/null
+++ b/src/notify/pr-lint-reporter/README.md
@@ -0,0 +1,74 @@
+
+
+  |
+ pr-lint-reporter |
+
+
+
+Posts a formatted lint analysis summary as a PR comment, aggregating results from all lint jobs. Updates the comment on subsequent runs instead of creating new ones.
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `github-token` | GitHub token with `pull-requests:write`, `issues:write`, `actions:read` and `checks:read` permissions | Yes | — |
+| `yamllint-result` | Result of the yamllint job | No | `skipped` |
+| `yamllint-files` | Space-separated list of YAML files linted | No | `` |
+| `actionlint-result` | Result of the actionlint job | No | `skipped` |
+| `actionlint-files` | Comma-separated list of workflow files linted | No | `` |
+| `pinned-actions-result` | Result of the pinned-actions job | No | `skipped` |
+| `pinned-actions-files` | Comma-separated list of files checked | No | `` |
+| `markdown-result` | Result of the markdown-link-check job | No | `skipped` |
+| `markdown-files` | Comma-separated list of markdown files checked | No | `` |
+| `typos-result` | Result of the typos job | No | `skipped` |
+| `typos-files` | Space-separated list of files checked for typos | No | `` |
+| `shellcheck-result` | Result of the shellcheck job | No | `skipped` |
+| `shellcheck-files` | Comma-separated list of YAML files checked by shellcheck | No | `` |
+| `readme-result` | Result of the readme-check job | No | `skipped` |
+| `readme-files` | Comma-separated list of files checked for README presence | No | `` |
+| `composite-schema-result` | Result of the composite-schema job | No | `skipped` |
+| `composite-schema-files` | Comma-separated list of action files validated by composite-schema | No | `` |
+
+## Usage as composite step
+
+```yaml
+jobs:
+ lint-report:
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ needs: [changed-files, yamllint, actionlint, pinned-actions, markdown-link-check, typos, shellcheck, readme-check, composite-schema]
+ if: always() && github.event_name == 'pull_request' && needs.changed-files.result == 'success'
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Post Lint Report
+ uses: LerianStudio/github-actions-shared-workflows/src/notify/pr-lint-reporter@develop
+ with:
+ github-token: ${{ secrets.MANAGE_TOKEN || github.token }}
+ yamllint-result: ${{ needs.yamllint.result }}
+ yamllint-files: ${{ needs.changed-files.outputs.yaml_files }}
+ actionlint-result: ${{ needs.actionlint.result }}
+ actionlint-files: ${{ needs.changed-files.outputs.workflow_files }}
+ pinned-actions-result: ${{ needs.pinned-actions.result }}
+ pinned-actions-files: ${{ needs.changed-files.outputs.action_files }}
+ markdown-result: ${{ needs.markdown-link-check.result }}
+ markdown-files: ${{ needs.changed-files.outputs.markdown_files }}
+ typos-result: ${{ needs.typos.result }}
+ typos-files: ${{ needs.changed-files.outputs.all_files }}
+ shellcheck-result: ${{ needs.shellcheck.result }}
+ shellcheck-files: ${{ needs.changed-files.outputs.action_files }}
+ readme-result: ${{ needs.readme-check.result }}
+ readme-files: ${{ needs.changed-files.outputs.action_files }}
+ composite-schema-result: ${{ needs.composite-schema.result }}
+ composite-schema-files: ${{ needs.changed-files.outputs.composite_files }}
+```
+
+## Required permissions
+
+```yaml
+permissions:
+ actions: read
+ pull-requests: write
+ issues: write
+ checks: read
+```
diff --git a/src/notify/pr-lint-reporter/action.yml b/src/notify/pr-lint-reporter/action.yml
new file mode 100644
index 0000000..afd8110
--- /dev/null
+++ b/src/notify/pr-lint-reporter/action.yml
@@ -0,0 +1,241 @@
+name: PR Lint Reporter
+description: Posts a formatted lint analysis summary comment on the pull request, updating it on subsequent runs.
+
+inputs:
+ github-token:
+ description: GitHub token with pull-requests:write, issues:write, actions:read and checks:read permissions
+ required: true
+ yamllint-result:
+ description: Result of the yamllint job (success/failure/skipped/cancelled)
+ required: false
+ default: "skipped"
+ yamllint-files:
+ description: Space-separated list of YAML files linted
+ required: false
+ default: ""
+ actionlint-result:
+ description: Result of the actionlint job (success/failure/skipped/cancelled)
+ required: false
+ default: "skipped"
+ actionlint-files:
+ description: Comma-separated list of action/workflow files linted
+ required: false
+ default: ""
+ pinned-actions-result:
+ description: Result of the pinned-actions job (success/failure/skipped/cancelled)
+ required: false
+ default: "skipped"
+ pinned-actions-files:
+ description: Comma-separated list of action/workflow files checked
+ required: false
+ default: ""
+ markdown-result:
+ description: Result of the markdown-link-check job (success/failure/skipped/cancelled)
+ required: false
+ default: "skipped"
+ markdown-files:
+ description: Comma-separated list of markdown files checked
+ required: false
+ default: ""
+ typos-result:
+ description: Result of the typos job (success/failure/skipped/cancelled)
+ required: false
+ default: "skipped"
+ typos-files:
+ description: Space-separated list of files checked for typos (empty = entire repository)
+ required: false
+ default: ""
+ shellcheck-result:
+ description: Result of the shellcheck job (success/failure/skipped/cancelled)
+ required: false
+ default: "skipped"
+ shellcheck-files:
+ description: Comma-separated list of YAML files checked by shellcheck
+ required: false
+ default: ""
+ readme-result:
+ description: Result of the readme-check job (success/failure/skipped/cancelled)
+ required: false
+ default: "skipped"
+ readme-files:
+ description: Comma-separated list of files checked for README presence
+ required: false
+ default: ""
+ composite-schema-result:
+ description: Result of the composite-schema job (success/failure/skipped/cancelled)
+ required: false
+ default: "skipped"
+ composite-schema-files:
+ description: Comma-separated list of action files validated by composite-schema
+ required: false
+ default: ""
+
+runs:
+ using: composite
+ steps:
+ - name: Post lint report to PR
+ uses: actions/github-script@v8
+ with:
+ github-token: ${{ inputs.github-token }}
+ script: |
+ const yamllintFiles = ${{ toJSON(inputs['yamllint-files']) }};
+ const actionlintFiles = ${{ toJSON(inputs['actionlint-files']) }};
+ const pinnedActionsFiles = ${{ toJSON(inputs['pinned-actions-files']) }};
+ const markdownFiles = ${{ toJSON(inputs['markdown-files']) }};
+ const typosFiles = ${{ toJSON(inputs['typos-files']) }};
+ const shellcheckFiles = ${{ toJSON(inputs['shellcheck-files']) }};
+ const readmeFiles = ${{ toJSON(inputs['readme-files']) }};
+ const compositeSchemaFiles = ${{ toJSON(inputs['composite-schema-files']) }};
+
+ const checks = [
+ {
+ jobName: 'YAML Lint',
+ label: 'YAML Lint',
+ result: '${{ inputs.yamllint-result }}',
+ files: yamllintFiles.trim().split(' ').filter(Boolean),
+ },
+ {
+ jobName: 'Action Lint',
+ label: 'Action Lint',
+ result: '${{ inputs.actionlint-result }}',
+ files: actionlintFiles.trim().split(',').filter(Boolean),
+ },
+ {
+ jobName: 'Pinned Actions Check',
+ label: 'Pinned Actions',
+ result: '${{ inputs.pinned-actions-result }}',
+ files: pinnedActionsFiles.trim().split(',').filter(Boolean),
+ },
+ {
+ jobName: 'Markdown Link Check',
+ label: 'Markdown Link Check',
+ result: '${{ inputs.markdown-result }}',
+ files: markdownFiles.trim().split(',').filter(Boolean),
+ },
+ {
+ jobName: 'Spelling Check',
+ label: 'Spelling Check',
+ result: '${{ inputs.typos-result }}',
+ files: typosFiles.trim().split(' ').filter(Boolean),
+ entireRepo: typosFiles.trim() === '',
+ },
+ {
+ jobName: 'Shell Check',
+ label: 'Shell Check',
+ result: '${{ inputs.shellcheck-result }}',
+ files: shellcheckFiles.trim().split(',').filter(Boolean),
+ },
+ {
+ jobName: 'README Check',
+ label: 'README Check',
+ result: '${{ inputs.readme-result }}',
+ files: readmeFiles.trim().split(',').filter(Boolean),
+ },
+ {
+ jobName: 'Composite Schema Lint',
+ label: 'Composite Schema',
+ result: '${{ inputs.composite-schema-result }}',
+ files: compositeSchemaFiles.trim().split(',').filter(Boolean),
+ },
+ ];
+
+ const icon = (r) => ({ success: '✅', failure: '❌', skipped: '⏭️' }[r] ?? '⚠️');
+
+ const filesSummary = (c) => {
+ if (c.entireRepo) return '_entire repository_';
+ if (!c.files || c.files.length === 0) return '_no changes_';
+ return `${c.files.length} file(s)`;
+ };
+
+ // ── Summary table ──
+ let body = '## 🔍 Lint Analysis\n\n';
+ body += '| Check | Files Scanned | Status |\n';
+ body += '|-------|:-------------:|:------:|\n';
+ for (const c of checks) {
+ body += `| ${c.label} | ${filesSummary(c)} | ${icon(c.result)} ${c.result} |\n`;
+ }
+
+ // ── Failures collapse with annotations ──
+ 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 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] || [];
+ body += `### ${c.label}\n\n`;
+
+ if (annotations.length === 0) {
+ body += `_No annotation details available — [view full logs](${runUrl})._\n\n`;
+ continue;
+ }
+
+ // Group by file path
+ const byFile = {};
+ for (const a of annotations) {
+ const key = a.path || '__general__';
+ (byFile[key] = byFile[key] || []).push(a);
+ }
+
+ for (const [file, errs] of Object.entries(byFile)) {
+ if (file === '__general__') {
+ for (const e of errs) body += `- ${e.message}\n`;
+ } else {
+ body += `**\`${file}\`**\n`;
+ for (const e of errs) {
+ const loc = e.start_line ? ` (line ${e.start_line})` : '';
+ body += `- \`${file}${loc}\` — ${e.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`;
+
+ // ── Post or update comment ──
+ const marker = '';
+ body = marker + '\n' + body;
+
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ per_page: 100,
+ });
+
+ const existing = comments.find(c => c.body?.includes(marker));
+ const params = { owner: context.repo.owner, repo: context.repo.repo, body };
+
+ if (existing) {
+ await github.rest.issues.updateComment({ ...params, comment_id: existing.id });
+ } else {
+ await github.rest.issues.createComment({ ...params, issue_number: context.issue.number });
+ }