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 @@ + + + + + +
Lerian

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 @@ + + + + + +
Lerian

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 @@ + + + + + +
Lerian

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 @@ + + + + + +
Lerian

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 @@ + + + + + +
Lerian

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 @@ + + + + + +
Lerian

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 @@ + + + + + +
Lerian

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 @@ + + + + + +
Lerian

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 @@ + + + + + +
Lerian

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 @@ + + + + + +
Lerian

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 }); + }