diff --git a/.claude/commands/composite.md b/.claude/commands/composite.md index ef8aa4b9..f7e4d0e9 100644 --- a/.claude/commands/composite.md +++ b/.claude/commands/composite.md @@ -73,6 +73,65 @@ runs: option: ${{ inputs.some-option }} ``` +## Configurability — defaults first, override when needed + +Every composite must be **self-contained with sensible defaults**. A caller should get a safe, useful result with zero extra configuration. Additional inputs allow the workflow (or caller) to override specific behaviors. + +**Composite layer — always define defaults:** + +```yaml +inputs: + enable-recommendations: + description: Include Docker Scout recommendations in the PR comment + required: false + default: "true" # ✅ composite works standalone + severity-threshold: + required: false + default: "high" # ✅ opinionated but safe default +``` + +**Rules:** +- All optional inputs must have a `default` — never `required: true` for feature flags +- Never hardcode feature flags — expose them as inputs so they can be overridden by the reusable workflow +- Step-level feature toggles (`if: inputs.enable_xxx`) belong in the **reusable workflow**, not inside the composite + +**Three-layer configurability flow:** + +``` +Caller repo Reusable workflow Composite +──────────────────────── ────────────────────────── ────────────────────────── +enable_docker_scout_ → enable_docker_scout_ → enable-recommendations: +recommendations: false recommendations ${{ inputs.... }} + (passes it down) +``` + +## Step section titles + +When a composite has more than one logical group of steps, separate them with a titled section comment: + +```yaml +runs: + using: composite + steps: + # ----------------- Setup ----------------- + - name: Login to Docker Registry + ... + + # ----------------- Scan ----------------- + - name: Docker Scout CVEs + ... + + # ----------------- Recommendations ----------------- + - name: Docker Scout Recommendations + ... +``` + +**Rules:** +- Format: `# ----------------- Title -----------------` (exact spacing) +- Add when there are 2+ logical groups of steps +- Title must be short and action-oriented +- Place the comment immediately before the first step — no blank line between comment and step + ## Design rules - **5–15 steps maximum** — split if larger @@ -100,6 +159,25 @@ src/deploy/helm-deploy/ ← any chart src/config/labels-sync/ ← any repo ``` +## Runner + +Composites inherit the runner from the calling job. All usage examples in `README.md` must specify `blacksmith-4vcpu-ubuntu-2404` as the runner: + +```yaml +jobs: + example: + runs-on: blacksmith-4vcpu-ubuntu-2404 # ✅ required runner + steps: + - uses: ./src/config/labels-sync +``` + +```yaml +# ❌ Never use other runners in examples +runs-on: ubuntu-latest +runs-on: ubuntu-22.04 +runs-on: self-hosted +``` + ## README.md requirements 1. Logo header — HTML table layout (logo left, `h1` title right) @@ -157,3 +235,23 @@ new-tool-category: ``` Never add `LerianStudio/*` actions to dependabot — pinned to `@main` intentionally. + +## Reserved input names — never use + +Never declare composite inputs using GitHub's reserved prefixes — they conflict with runtime variables and break jobs: + +```yaml +# ❌ Reserved — conflicts with GitHub's runtime variable +inputs: + GITHUB_TOKEN: + GITHUB_SHA: + ACTIONS_RUNTIME_TOKEN: + RUNNER_OS: + +# ✅ Use kebab-case and distinct names +inputs: + github-token: + manage-token: +``` + +Reserved prefixes: `GITHUB_*`, `ACTIONS_*`, `RUNNER_*`. diff --git a/.claude/commands/gha.md b/.claude/commands/gha.md index 90f12236..95d82653 100644 --- a/.claude/commands/gha.md +++ b/.claude/commands/gha.md @@ -197,6 +197,28 @@ jobs: --- +## Runner + +All jobs in reusable workflows must use `blacksmith-4vcpu-ubuntu-2404` as the runner: + +```yaml +jobs: + build: + runs-on: blacksmith-4vcpu-ubuntu-2404 # ✅ required runner + # ... + + deploy: + runs-on: blacksmith-4vcpu-ubuntu-2404 # ✅ required runner + # ... +``` + +```yaml +# ❌ Never use other runners +runs-on: ubuntu-latest +runs-on: ubuntu-22.04 +runs-on: self-hosted +``` + ## Workflow structure Every reusable workflow must: @@ -231,6 +253,84 @@ on: default: false ``` +## Step section titles + +When a job or composite has more than one logical group of steps, separate each group with a titled section comment: + +```yaml + # ----------------- Security Scans ----------------- + - name: Trivy Secret Scan + ... + + # ----------------- Docker Scout Analysis ----------------- + - name: Docker Scout + ... + + # ----------------- PR Comment with Security Findings ----------------- + - name: Post Results to PR + ... +``` + +**Rules:** +- Format: `# ----------------- Title -----------------` (exact spacing, dashes on both sides) +- Add when there are 2+ logical groups of steps in the same job or composite +- Title must be short and action-oriented (e.g. "Build & Push", "Security Scans", "Notifications") +- Place the comment immediately before the first step of the group — no blank line between comment and step +- Single-group jobs with 2–3 tightly related steps do not need a title + +## Configurability — three-layer defaults and overrides + +Composites, reusable workflows, and callers each have a responsibility in the configurability chain. Follow this model to keep things flexible without requiring callers to know composite internals. + +**Composite layer** — always define sensible defaults, never `required: true` for feature flags: + +```yaml +inputs: + enable-recommendations: + description: Include Docker Scout recommendations in the PR comment + required: false + default: "true" # ✅ composite works standalone with no config +``` + +**Reusable workflow layer** — expose a matching input for every optional composite feature; pass it down explicitly: + +```yaml +on: + workflow_call: + inputs: + enable_docker_scout: + description: Run Docker Scout vulnerability scan + required: false + type: boolean + default: true + enable_docker_scout_recommendations: + required: false + type: boolean + default: true + +jobs: + scan: + steps: + - name: Docker Scout + if: inputs.enable_docker_scout # step-level gate — skip entire composite when disabled + uses: LerianStudio/github-actions-shared-workflows/src/security/docker-scout@v1.2.3 + with: + enable-recommendations: ${{ inputs.enable_docker_scout_recommendations }} +``` + +**Rules:** +- Feature toggle `if:` conditions belong in the **reusable workflow** (step or job level), not inside the composite +- Input naming: workflow inputs → `snake_case`, composite inputs → `kebab-case` +- Composite defaults must be safe and sensible standalone; workflow defaults may be more opinionated + +``` +Caller repo Reusable workflow Composite +──────────────────────── ────────────────────────── ────────────────────────── +enable_docker_scout: false → if: inputs.enable_xxx → step is never reached +enable_docker_scout_ → enable-recommendations: → ${{ inputs.enable- + recommendations: false ${{ inputs.... }} recommendations }} +``` + ## dry_run pattern | Mode | Goal | Log style | @@ -262,12 +362,20 @@ on: **Real run (`false`):** no extra `echo`, no debug flags, let failures surface via exit codes, one `::notice::` summary on success at most. -## Local path rule (critical) +## Composite action references (critical) -```yaml -uses: ./src/setup/setup-go # ✅ composite version matches workflow version -uses: LerianStudio/...@main # ❌ breaks versioning for callers on older tags -``` +In reusable workflows (`workflow_call`), `uses: ./path` resolves to the **caller's workspace**, not this repository. This means `./src/...` only works when the caller IS this repo (i.e., `self-*` workflows). + +- **Workflows called by external repos** — must use an external ref: + ```yaml + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@v1.2.3 # ✅ pinned + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@develop # ⚠️ testing only + uses: ./src/notify/discord-release # ❌ resolves to caller's workspace, will fail + ``` +- **`self-*` workflows (internal only)** — local path for reusable workflow only: + ```yaml + uses: ./.github/workflows/labels-sync.yml # ✅ caller is this repo + ``` ## Secrets management @@ -353,8 +461,8 @@ runs: jobs: # invalid — composites have steps, not jobs build: ... -# ❌ External ref for composite inside a reusable workflow -uses: LerianStudio/github-actions-shared-workflows/src/setup-go@main +# ❌ Local path for composite in a workflow called by external repos +uses: ./src/setup-go # resolves to caller's workspace, not this repo # ❌ Mutable ref on third-party actions uses: some-action/tool@main @@ -490,3 +598,25 @@ new-tool-category: ``` Never add `LerianStudio/*` actions to dependabot — pinned to `@main` intentionally. + +## Reserved names — never use as secret, input, or env var + +Never use GitHub's reserved prefixes for custom secrets, inputs, or env vars — they conflict with runtime variables and break jobs silently: + +```yaml +# ❌ Reserved — GITHUB_TOKEN is injected automatically, cannot be a named secret +on: + workflow_call: + secrets: + GITHUB_TOKEN: # breaks callers + required: true + +# ✅ Use a distinct name +on: + workflow_call: + secrets: + MANAGE_TOKEN: + required: false +``` + +Reserved prefixes (workflows and composites): `GITHUB_*`, `ACTIONS_*`, `RUNNER_*`. diff --git a/.claude/commands/workflow.md b/.claude/commands/workflow.md index 6df05550..cd073df4 100644 --- a/.claude/commands/workflow.md +++ b/.claude/commands/workflow.md @@ -92,6 +92,24 @@ jobs: --- +## Runner + +All jobs in reusable workflows must use `blacksmith-4vcpu-ubuntu-2404` as the runner: + +```yaml +jobs: + build: + runs-on: blacksmith-4vcpu-ubuntu-2404 # ✅ required runner + # ... +``` + +```yaml +# ❌ Never use other runners +runs-on: ubuntu-latest +runs-on: ubuntu-22.04 +runs-on: self-hosted +``` + ## Workflow structure Every reusable workflow must: @@ -126,6 +144,41 @@ on: default: false ``` +## Configurability — expose composite toggles as workflow inputs + +Reusable workflows act as a **configuration bridge** between callers and composites. Every optional feature in a composite must be surfaceable as a workflow input, so callers can enable or disable it without touching the composite. + +**Rules:** +- For every optional feature in a composite (e.g. `enable-recommendations`), declare a matching `workflow_call` input +- Pass the workflow input explicitly to the composite via `with:` +- Use `if: inputs.enable_xxx` at the **step or job level** in the workflow to skip the composite call entirely when disabled — never gate this inside the composite itself +- Input naming: workflow inputs use `snake_case`, composite inputs use `kebab-case` + +```yaml +on: + workflow_call: + inputs: + enable_docker_scout: + description: Run Docker Scout vulnerability scan + required: false + type: boolean + default: true + enable_docker_scout_recommendations: + description: Include Docker Scout recommendations in the PR comment + required: false + type: boolean + default: true + +jobs: + scan: + steps: + - name: Docker Scout + if: inputs.enable_docker_scout # skip entire step when disabled + uses: LerianStudio/github-actions-shared-workflows/src/security/docker-scout@v1.2.3 + with: + enable-recommendations: ${{ inputs.enable_docker_scout_recommendations }} +``` + ## dry_run pattern The two modes have opposite goals — design them accordingly: @@ -159,13 +212,45 @@ The two modes have opposite goals — design them accordingly: **Real run (`false`):** no extra `echo`, no debug flags, let failures surface via exit codes, one `::notice::` summary on success at most. -## Local path rule (critical) +## Step section titles + +When a job has more than one logical group of steps, separate each group with a titled section comment: ```yaml -uses: ./src/setup/setup-go # ✅ composite version matches workflow version -uses: LerianStudio/...@main # ❌ breaks versioning for callers on older tags + # ----------------- Security Scans ----------------- + - name: Trivy Secret Scan + ... + + # ----------------- Docker Scout Analysis ----------------- + - name: Docker Scout + ... + + # ----------------- PR Comment with Security Findings ----------------- + - name: Post Results to PR + ... ``` +**Rules:** +- Format: `# ----------------- Title -----------------` (exact spacing) +- Add when there are 2+ logical groups in the same job +- Title must be short and action-oriented (e.g. "Build & Push", "Notifications") +- Place the comment immediately before the first step of the group — no blank line between comment and step + +## Composite action references (critical) + +In reusable workflows (`workflow_call`), `uses: ./path` resolves to the **caller's workspace**, not this repository. This means `./src/...` only works when the caller IS this repo (i.e., `self-*` workflows). + +- **Workflows called by external repos** — must use an external ref pinned to a release tag: + ```yaml + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@v1.2.3 # ✅ pinned + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@develop # ⚠️ testing only + uses: ./src/notify/discord-release # ❌ resolves to caller's workspace, will fail + ``` +- **`self-*` workflows (internal only)** — may use local path for reusable workflows only (not composites): + ```yaml + uses: ./.github/workflows/labels-sync.yml # ✅ caller is this repo + ``` + ## Secrets management ```yaml @@ -250,8 +335,8 @@ runs: jobs: # invalid — composites have steps, not jobs build: ... -# ❌ External ref for composite inside a reusable workflow -uses: LerianStudio/github-actions-shared-workflows/src/setup-go@main +# ❌ Local path for composite in a workflow called by external repos +uses: ./src/setup-go # resolves to caller's workspace, not this repo # ❌ Mutable ref on third-party actions uses: some-action/tool@main @@ -264,3 +349,25 @@ uses: some-action/tool@main - Never interpolate untrusted user input directly into `run:` commands - Never print secrets via `echo`, env dumps, or step summaries - Complex conditional logic belongs in the workflow, not in composites + +### Reserved names — never use as custom secret or input names + +Never declare secrets or inputs using GitHub's reserved prefixes — they break jobs silently: + +```yaml +# ❌ Breaks jobs — GITHUB_TOKEN is provided automatically, cannot be declared as a named secret +on: + workflow_call: + secrets: + GITHUB_TOKEN: # reserved + required: true + +# ✅ Use a distinct name for custom tokens +on: + workflow_call: + secrets: + MANAGE_TOKEN: + required: false +``` + +Reserved prefixes: `GITHUB_*`, `ACTIONS_*`, `RUNNER_*` — never use as custom secret, input, or env var names. diff --git a/.cursor/rules/composite-actions.mdc b/.cursor/rules/composite-actions.mdc index fe8a4531..df5c3f14 100644 --- a/.cursor/rules/composite-actions.mdc +++ b/.cursor/rules/composite-actions.mdc @@ -85,6 +85,112 @@ runs: option: ${{ inputs.some-option }} ``` +## Configurability — defaults first, override when needed + +Every composite must be **self-contained with sensible defaults**. A caller should be able to drop it in with zero extra configuration and get a safe, useful result. Additional inputs allow the reusable workflow (or caller) to override specific behaviors. + +### Composite layer — always define defaults + +All optional inputs must declare a `default` that makes the composite safe and functional on its own: + +```yaml +inputs: + enable-recommendations: + description: Include Docker Scout recommendations in the PR comment + required: false + default: "true" # ✅ safe default — composite works standalone + severity-threshold: + description: Minimum severity to report (critical, high, medium, low) + required: false + default: "high" # ✅ opinionated but sensible default +``` + +### Reusable workflow layer — expose toggles for each configurable step + +When a composite has optional features (steps that can be turned on/off), the reusable workflow must expose a matching input and pass it down. This gives callers control without touching the composite directly: + +```yaml +# reusable workflow inputs — mirrors composite flags +on: + workflow_call: + inputs: + enable_docker_scout: + description: Run Docker Scout vulnerability scan + required: false + type: boolean + default: true + enable_docker_scout_recommendations: + description: Include Docker Scout recommendations in the PR comment + required: false + type: boolean + default: true + +jobs: + scan: + steps: + - name: Docker Scout + if: inputs.enable_docker_scout + uses: LerianStudio/github-actions-shared-workflows/src/security/docker-scout@v1.2.3 + with: + enable-recommendations: ${{ inputs.enable_docker_scout_recommendations }} +``` + +### Three-layer flow + +``` +Caller repo Reusable workflow Composite +──────────────────────── ────────────────────────── ────────────────────────── +enable_docker_scout_ → enable_docker_scout_ → enable-recommendations: +recommendations: false recommendations ${{ inputs.... }} + (passes it down) (step-level if condition) +``` + +**Rules:** +- Composite inputs → always have a `default`; never `required: true` for feature flags +- Reusable workflow inputs → match composite flag names (use `_` instead of `-`); pass values explicitly to the composite step +- Step-level toggles (`if: inputs.enable_xxx`) belong in the **reusable workflow**, not inside the composite — composites run the step based on their own inputs +- Never hardcode feature flags inside a composite — always expose them as inputs so they can be overridden + +## Skip-enabling outputs + +Composite actions that perform conditional work (change detection, feature-flag checks, validation gates) **must expose boolean outputs** so the calling workflow can skip downstream steps or jobs. + +```yaml +outputs: + has_changes: + description: 'Whether any changes were detected (true/false)' + value: ${{ steps.detect.outputs.has_changes }} +``` + +Naming convention: `has_` (e.g. `has_changes`, `has_updates`, `has_drift`). Value must be the string `'true'` or `'false'`. + +## Step section titles + +When a composite has more than one logical group of steps, separate them with a titled section comment: + +```yaml +runs: + using: composite + steps: + # ----------------- Setup ----------------- + - name: Login to Docker Registry + ... + + # ----------------- Scan ----------------- + - name: Docker Scout CVEs + ... + + # ----------------- Recommendations ----------------- + - name: Docker Scout Recommendations + ... +``` + +**Rules:** +- Use the exact format: `# ----------------- Title -----------------` +- Add a section title whenever there are 2 or more logical groups of steps +- Title must be short and action-oriented +- Place the comment immediately before the first step of the group — no blank line between comment and step + ## Design rules - **5–15 steps maximum** — split if larger @@ -112,6 +218,25 @@ src/deploy/helm-deploy/ ← any chart src/config/labels-sync/ ← any repo ``` +## Runner + +Composites inherit the runner from the calling job. All usage examples in `README.md` must specify `blacksmith-4vcpu-ubuntu-2404` as the runner: + +```yaml +jobs: + example: + runs-on: blacksmith-4vcpu-ubuntu-2404 # ✅ required runner + steps: + - uses: ./src/config/labels-sync +``` + +```yaml +# ❌ Never use other runners in examples +runs-on: ubuntu-latest +runs-on: ubuntu-22.04 +runs-on: self-hosted +``` + ## README.md requirements 1. Logo header — HTML table layout (logo left, `h1` title right) @@ -171,3 +296,23 @@ new-tool-category: ``` Never add `LerianStudio/*` actions to dependabot — they are pinned to `@main` intentionally. + +## Reserved names — never use as input names + +Never declare a composite input using GitHub's reserved prefixes — they will be silently overridden or cause failures: + +```yaml +# ❌ Reserved — will conflict with the runtime variable +inputs: + GITHUB_TOKEN: + GITHUB_SHA: + ACTIONS_RUNTIME_TOKEN: + RUNNER_OS: + +# ✅ Use distinct names +inputs: + github-token: # conventional kebab-case name for tokens + manage-token: +``` + +Reserved prefixes: `GITHUB_*`, `ACTIONS_*`, `RUNNER_*`. diff --git a/.cursor/rules/reusable-workflows.mdc b/.cursor/rules/reusable-workflows.mdc index e2413c0a..878787da 100644 --- a/.cursor/rules/reusable-workflows.mdc +++ b/.cursor/rules/reusable-workflows.mdc @@ -103,6 +103,24 @@ jobs: --- +## Runner + +All jobs in reusable workflows must use `blacksmith-4vcpu-ubuntu-2404` as the runner: + +```yaml +jobs: + build: + runs-on: blacksmith-4vcpu-ubuntu-2404 # ✅ required runner + # ... +``` + +```yaml +# ❌ Never use other runners +runs-on: ubuntu-latest +runs-on: ubuntu-22.04 +runs-on: self-hosted +``` + ## Workflow structure Every reusable workflow must: @@ -177,15 +195,122 @@ The two modes have opposite goals — design them accordingly: - Let failures surface naturally via non-zero exit codes - Add a single `::notice::` summary line only after successful completion if useful -## Local path rule (critical) +## Configurability — expose composite toggles as workflow inputs + +Reusable workflows act as a **configuration bridge** between callers and composites. Every optional feature in a composite must be surfaceable as an input in the reusable workflow, so callers can enable or disable it without forking the workflow. + +**Rules:** +- For every optional feature in a composite (e.g. `enable-recommendations`), declare a matching input in the workflow +- Pass the workflow input explicitly to the composite step via `with:` +- Use `if: inputs.enable_xxx` at the **step or job level** in the workflow to skip the entire composite call when the feature is disabled — do not gate this inside the composite +- Keep input names consistent: workflow inputs use `snake_case`, composite inputs use `kebab-case` + +```yaml +# reusable workflow — exposes composite features as inputs +on: + workflow_call: + inputs: + enable_docker_scout: + description: Run Docker Scout vulnerability scan + required: false + type: boolean + default: true + enable_docker_scout_recommendations: + description: Include Docker Scout recommendations in the PR comment + required: false + type: boolean + default: true + +jobs: + scan: + steps: + # entire step skipped when disabled — never reaches the composite + - name: Docker Scout + if: inputs.enable_docker_scout + uses: LerianStudio/github-actions-shared-workflows/src/security/docker-scout@v1.2.3 + with: + enable-recommendations: ${{ inputs.enable_docker_scout_recommendations }} + # ↑ override composite default; composite still has its own fallback +``` + +**Three-layer configurability flow:** + +``` +Caller repo Reusable workflow Composite +───────────────────────── ───────────────────────────── ──────────────────── +enable_docker_scout: false → if: inputs.enable_docker_scout → step is skipped +enable_docker_scout_ → enable-recommendations: → ${{ inputs.enable- + recommendations: false ${{ inputs.... }} recommendations }} + (passes value down) (runs conditionally) +``` + +## Skip-enabling outputs + +Every reusable workflow that performs conditional work (change detection, feature flags, environment checks) **must expose boolean outputs** so callers can skip downstream jobs when there is nothing to do. + +```yaml +on: + workflow_call: + outputs: + has_builds: + description: 'Whether any components were detected for building (true/false)' + value: ${{ jobs.prepare.outputs.has_builds }} +``` + +Naming convention: `has_` (e.g. `has_builds`, `has_changes`, `has_releases`). Value must be the string `'true'` or `'false'`. -Inside a reusable workflow, always reference composites with a local path: +Callers use these outputs to gate dependent jobs: ```yaml -uses: ./src/setup/setup-go # ✅ composite version matches workflow version -uses: LerianStudio/...@main # ❌ breaks versioning for callers on older tags +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/build.yml@v1.2.3 + deploy: + needs: build + if: needs.build.outputs.has_builds == 'true' + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/deploy.yml@v1.2.3 ``` +## Step section titles + +When a job has more than one logical group of steps, separate each group with a titled section comment: + +```yaml +# ----------------- Security Scans ----------------- +- name: Trivy Secret Scan + ... + +# ----------------- Docker Scout Analysis ----------------- +- name: Docker Scout + ... + +# ----------------- PR Comment with Security Findings ----------------- +- name: Post Security Scan Results to PR + ... +``` + +**Rules:** +- Use the exact format: `# ----------------- Title -----------------` +- Title must be short, action-oriented, and describe the group's purpose (e.g. "Build & Push", "Security Scans", "Notifications") +- Add a section title whenever there are 2 or more logical groups within the same job +- Single-group jobs or jobs with 2–3 tightly related steps do not need a section title +- Place the comment on the line immediately before the first step of the group (no blank line between comment and step) + +## Composite action references (critical) + +In reusable workflows (`workflow_call`), `uses: ./path` resolves to the **caller's workspace**, not this repository. This means `./src/...` only works when the caller IS this repo (i.e., `self-*` workflows). + +- **Workflows called by external repos** — must use an external ref pinned to a release tag: + ```yaml + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@v1.2.3 # ✅ pinned + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@develop # ⚠️ testing only + uses: ./src/notify/discord-release # ❌ resolves to caller's workspace, will fail + ``` +- **`self-*` workflows (internal only)** — use a local path: + ```yaml + uses: ./.github/workflows/labels-sync.yml # ✅ caller is this repo + ``` + ## Secrets management Secrets must always flow from the caller — never hardcoded inside reusable workflows: @@ -274,8 +399,8 @@ runs: jobs: # invalid — composites have steps, not jobs build: ... -# ❌ External ref for composite inside a reusable workflow -uses: LerianStudio/github-actions-shared-workflows/src/setup-go@main # breaks versioning +# ❌ Local path for composite in a workflow called by external repos +uses: ./src/setup-go # resolves to caller's workspace, not this repo # ❌ Mutable ref on third-party actions uses: some-action/tool@main # use a specific tag or SHA @@ -288,3 +413,33 @@ uses: some-action/tool@main # use a specific tag or SHA - Never interpolate untrusted user input directly into `run:` commands - Never print secrets via `echo`, env dumps, or step summaries - Complex conditional logic belongs in the workflow (not in composites) — full log visibility + +### Reserved names — never use as custom secret or input names + +GitHub reserves the `GITHUB_*` prefix for all built-in variables and the `ACTIONS_*` prefix for runner internals. Declaring a custom secret or input with these names causes the job to fail silently or be ignored: + +```yaml +# ❌ Breaks jobs — GitHub does not allow passing GITHUB_TOKEN as a named secret +on: + workflow_call: + secrets: + GITHUB_TOKEN: # reserved — each job already receives it automatically + required: true + +# ✅ Use a different name for custom tokens +on: + workflow_call: + secrets: + MANAGE_TOKEN: # custom PAT passed by the caller + required: false +``` + +**Reserved prefixes to never use in custom secrets, inputs, or env vars:** + +| Prefix | Owner | Examples | +|---|---|---| +| `GITHUB_*` | GitHub Actions runtime | `GITHUB_TOKEN`, `GITHUB_SHA`, `GITHUB_REF` | +| `ACTIONS_*` | GitHub runner internals | `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_CACHE_URL` | +| `RUNNER_*` | Runner metadata | `RUNNER_OS`, `RUNNER_TEMP` | + +If a caller needs to pass a GitHub token with elevated permissions, use a distinct name like `MANAGE_TOKEN`, `BOT_TOKEN`, or `GH_PAT`. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..12ce746f --- /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 6ab52460..ecf30ca0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -83,11 +83,12 @@ updates: - "minor" - "patch" - # Slack notification actions + # Notification actions (Slack, Discord) notifications: patterns: - "rtCamp/action-slack-notify" - "slackapi/slack-github-action" + - "SethCohen/github-releases-to-discord" update-types: - "major" - "minor" @@ -102,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" - - "gaurav-nelson/github-action-markdown-link-check" - "actions/github-script" - "mikefarah/yq" update-types: diff --git a/.github/labels.yml b/.github/labels.yml index cfb642e3..4a6a6cf1 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -83,3 +83,11 @@ - name: config color: "c5def5" description: Changes to repository configuration composite actions (src/config/) + +- 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 00000000..b70da114 --- /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/api-dog-e2e-tests.yml b/.github/workflows/api-dog-e2e-tests.yml index f108d55b..d29c1335 100644 --- a/.github/workflows/api-dog-e2e-tests.yml +++ b/.github/workflows/api-dog-e2e-tests.yml @@ -100,7 +100,7 @@ jobs: - name: Upload Test Reports if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: apidog-e2e-test-reports-${{ env.TAG_TYPE }} path: ./apidog-reports diff --git a/.github/workflows/branch-cleanup.yml b/.github/workflows/branch-cleanup.yml index 7ef37a79..25b20639 100644 --- a/.github/workflows/branch-cleanup.yml +++ b/.github/workflows/branch-cleanup.yml @@ -23,9 +23,6 @@ on: required: false type: string default: "" - secrets: - GITHUB_TOKEN: - required: false workflow_dispatch: inputs: stale_days: @@ -48,13 +45,13 @@ permissions: jobs: cleanup: name: Clean up branches - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Run branch cleanup - uses: ./src/config/branch-cleanup + uses: LerianStudio/github-actions-shared-workflows/src/config/branch-cleanup@develop with: github-token: ${{ secrets.GITHUB_TOKEN }} stale-days: ${{ inputs.stale_days }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9154aa6d..053569ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ name: "Build and Push Docker Images" # Supports both DockerHub and GHCR (GitHub Container Registry) # # Features: -# - Monorepo support with optional change detection via LerianStudio/github-actions-changed-paths +# - Monorepo support with optional change detection via internal changed-paths composite # - Multi-registry support (DockerHub and/or GHCR) # - Platform strategy: beta/rc builds amd64 only, release builds amd64+arm64 # - Semantic versioning tags @@ -13,6 +13,10 @@ name: "Build and Push Docker Images" on: workflow_call: + outputs: + has_builds: + description: 'Whether any components were detected for building (true/false). Use this to skip downstream jobs when no paths changed.' + value: ${{ jobs.prepare.outputs.has_builds }} inputs: runner_type: description: 'Runner to use for the workflow' @@ -23,6 +27,11 @@ on: type: string required: false default: '' + shared_paths: + description: 'Newline-separated path patterns (e.g., "go.mod\ngo.sum") that trigger a build for ALL components when matched by any changed file.' + type: string + required: false + default: '' path_level: description: 'Limits the path to the first N segments (e.g., 2 -> "apps/agent")' type: string @@ -109,6 +118,10 @@ on: description: 'Normalize changed paths to their filter path (e.g., components/app/cmd -> components/app). Recommended for monorepos to avoid duplicate builds.' type: boolean default: true + force_multiplatform: + description: 'Force multi-platform build (amd64+arm64) even for beta/rc tags' + type: boolean + default: false permissions: contents: read @@ -126,14 +139,15 @@ jobs: - name: Get changed paths (monorepo) if: inputs.filter_paths != '' id: changed-paths - uses: LerianStudio/github-actions-changed-paths@main + uses: LerianStudio/github-actions-shared-workflows/src/config/changed-paths@develop with: - filter_paths: ${{ inputs.filter_paths }} - path_level: ${{ inputs.path_level }} - get_app_name: 'true' - app_name_prefix: ${{ inputs.app_name_prefix }} - app_name_overrides: ${{ inputs.app_name_overrides }} - normalize_to_filter: ${{ inputs.normalize_to_filter }} + filter-paths: ${{ inputs.filter_paths }} + shared-paths: ${{ inputs.shared_paths }} + path-level: ${{ inputs.path_level }} + get-app-name: 'true' + app-name-prefix: ${{ inputs.app_name_prefix }} + app-name-overrides: ${{ inputs.app_name_overrides }} + normalize-to-filter: ${{ inputs.normalize_to_filter }} - name: Set matrix id: set-matrix @@ -141,16 +155,16 @@ jobs: if [ -z "${{ inputs.filter_paths }}" ]; then # Single app mode - build from root APP_NAME="${{ github.event.repository.name }}" - echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> $GITHUB_OUTPUT - echo "has_builds=true" >> $GITHUB_OUTPUT + echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> "$GITHUB_OUTPUT" + echo "has_builds=true" >> "$GITHUB_OUTPUT" else MATRIX='${{ steps.changed-paths.outputs.matrix }}' if [ "$MATRIX" == "[]" ] || [ -z "$MATRIX" ]; then - echo "matrix=[]" >> $GITHUB_OUTPUT - echo "has_builds=false" >> $GITHUB_OUTPUT + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_builds=false" >> "$GITHUB_OUTPUT" else - echo "matrix=$MATRIX" >> $GITHUB_OUTPUT - echo "has_builds=true" >> $GITHUB_OUTPUT + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "has_builds=true" >> "$GITHUB_OUTPUT" fi fi @@ -160,12 +174,18 @@ jobs: TAG="${GITHUB_REF#refs/tags/}" if [[ "$TAG" == *"-beta"* ]] || [[ "$TAG" == *"-rc"* ]]; then - echo "platforms=linux/amd64" >> $GITHUB_OUTPUT - echo "is_release=false" >> $GITHUB_OUTPUT - echo "Building for amd64 only (beta/rc tag)" + if [ "${{ inputs.force_multiplatform }}" == "true" ]; then + echo "platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT" + echo "is_release=false" >> "$GITHUB_OUTPUT" + echo "Building for amd64 and arm64 (beta/rc tag with force_multiplatform)" + else + echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT" + echo "is_release=false" >> "$GITHUB_OUTPUT" + echo "Building for amd64 only (beta/rc tag)" + fi else - echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT - echo "is_release=true" >> $GITHUB_OUTPUT + echo "platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT" + echo "is_release=true" >> "$GITHUB_OUTPUT" echo "Building for amd64 and arm64 (release tag)" fi @@ -185,22 +205,22 @@ jobs: uses: actions/checkout@v6 - name: Set up QEMU - if: needs.prepare.outputs.is_release == 'true' - uses: docker/setup-qemu-action@v3 + if: contains(needs.prepare.outputs.platforms, 'arm64') + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to DockerHub if: inputs.enable_dockerhub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to GHCR if: inputs.enable_ghcr - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -209,7 +229,7 @@ jobs: - name: Normalize repository owner to lowercase id: normalize run: | - echo "owner_lower=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + echo "owner_lower=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - name: Set image names id: image-names @@ -221,11 +241,11 @@ jobs: else GHCR_ORG=$(echo "$GHCR_ORG" | tr '[:upper:]' '[:lower:]') fi - + if [ "${{ inputs.enable_dockerhub }}" == "true" ]; then IMAGES="${{ inputs.dockerhub_org }}/${{ matrix.app.name }}" fi - + if [ "${{ inputs.enable_ghcr }}" == "true" ]; then if [ -n "$IMAGES" ]; then IMAGES="${IMAGES},ghcr.io/${GHCR_ORG}/${{ matrix.app.name }}" @@ -233,8 +253,8 @@ jobs: IMAGES="ghcr.io/${GHCR_ORG}/${{ matrix.app.name }}" fi fi - - echo "images=$IMAGES" >> $GITHUB_OUTPUT + + echo "images=$IMAGES" >> "$GITHUB_OUTPUT" - name: Extract version from tag id: version @@ -243,13 +263,14 @@ jobs: # Strip app prefix by finding -v and extracting from there # e.g., agent-v1.0.0-beta.1 -> v1.0.0-beta.1 # e.g., control-plane-v1.0.0-beta.1 -> v1.0.0-beta.1 + # shellcheck disable=SC2001 VERSION=$(echo "$TAG" | sed 's/.*-\(v[0-9]\)/\1/') - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Extracted version: $VERSION from tag: $TAG" - name: Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ steps.image-names.outputs.images }} tags: | @@ -258,7 +279,7 @@ jobs: type=semver,pattern={{major}},value=${{ steps.version.outputs.version }},enable=${{ needs.prepare.outputs.is_release }} - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ${{ inputs.build_context }} file: ${{ matrix.app.working_dir }}/${{ inputs.dockerfile_name }} @@ -266,6 +287,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + sbom: generator=docker/scout-sbom-indexer:latest + provenance: mode=max cache-from: type=gha cache-to: type=gha,mode=max secrets: | @@ -285,7 +308,7 @@ jobs: - name: Upload GitOps tag artifact if: inputs.enable_gitops_artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: gitops-tags-${{ matrix.app.name }} path: gitops-tags/ diff --git a/.github/workflows/changed-paths.yml b/.github/workflows/changed-paths.yml deleted file mode 100644 index 5758c356..00000000 --- a/.github/workflows/changed-paths.yml +++ /dev/null @@ -1,167 +0,0 @@ -name: Changed Paths - -on: - workflow_call: - inputs: - filter_paths: - description: 'JSON array of path prefixes to filter results (e.g., ["components/mdz", "components/transaction"])' - required: false - type: string - default: '' - path_level: - description: 'Limits the path to the first N segments (e.g., 2 -> "components/transactions")' - required: false - type: number - default: 0 - get_app_name: - description: 'If true, outputs a matrix of objects with app name and working directory' - required: false - type: boolean - default: false - app_name_prefix: - description: 'Prefix to add to each app name when get_app_name is true' - required: false - type: string - default: '' - runner_type: - description: 'GitHub runner type' - required: false - type: string - default: 'blacksmith-4vcpu-ubuntu-2404' - - outputs: - matrix: - description: 'JSON array of changed directories' - value: ${{ jobs.get-changed-paths.outputs.matrix }} - has_changes: - description: 'Boolean indicating if there are any changes' - value: ${{ jobs.get-changed-paths.outputs.has_changes }} - -jobs: - get-changed-paths: - name: Get Changed Paths - runs-on: ${{ inputs.runner_type }} - outputs: - matrix: ${{ steps.dirs.outputs.matrix }} - has_changes: ${{ steps.dirs.outputs.has_changes }} - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Get changed files - id: changed - shell: bash - run: | - if [[ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ]] || [[ -z "${{ github.event.before }}" ]]; then - # For tags or when before is not available, compare with the previous commit - PREV_COMMIT=$(git rev-parse HEAD^) - if [[ $? -eq 0 ]]; then - FILES=$(git diff --name-only $PREV_COMMIT HEAD) - else - # Fallback for first commit - FILES=$(git ls-tree -r --name-only HEAD) - fi - else - # Normal case - diff between commits - FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }}) - fi - printf "files<> "$GITHUB_OUTPUT" - - - name: Extract changed directories - id: dirs - shell: bash - run: | - FILES="${{ steps.changed.outputs.files }}" - FILTER_PATHS='${{ inputs.filter_paths }}' - PATH_LEVEL="${{ inputs.path_level }}" - GET_APP_NAME="${{ inputs.get_app_name }}" - APP_NAME_PREFIX="${{ inputs.app_name_prefix }}" - - if [[ -z "$FILES" ]]; then - echo "No files changed." - printf "matrix=[]\n" >> "$GITHUB_OUTPUT" - printf "has_changes=false\n" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Get directory for each file - DIRS=$(echo "$FILES" | xargs -n1 dirname) - - # Trim to first N path segments if specified - if [[ -n "$PATH_LEVEL" ]] && [[ "$PATH_LEVEL" -gt 0 ]]; then - echo "Trimming paths to first $PATH_LEVEL segments" - DIRS=$(echo "$DIRS" | cut -d'/' -f-"$PATH_LEVEL") - fi - - # Filter paths if filter_paths is provided (JSON array format) - if [[ -n "$FILTER_PATHS" ]] && [[ "$FILTER_PATHS" != "[]" ]] && [[ "$FILTER_PATHS" != "" ]]; then - echo "Filtering directories using list:" - echo "$FILTER_PATHS" - - # Parse JSON array into newline-separated values - FILTER_LIST=$(echo "$FILTER_PATHS" | jq -r '.[]' 2>/dev/null || echo "") - - if [[ -n "$FILTER_LIST" ]]; then - FILTERED="" - while read -r DIR; do - while read -r FILTER; do - if [[ "$DIR" == "$FILTER"* ]]; then - FILTERED+="$DIR"$'\n' - break - fi - done <<< "$FILTER_LIST" - done <<< "$DIRS" - - # If nothing matched, exit - if [[ -z "$FILTERED" ]]; then - echo "No matching directories found after filtering." - printf "matrix=[]\n" >> "$GITHUB_OUTPUT" - printf "has_changes=false\n" >> "$GITHUB_OUTPUT" - exit 0 - fi - - DIRS="$FILTERED" - fi - fi - - # Deduplicate and remove empty lines - DIRS=$(echo "$DIRS" | grep -v '^$' | sort -u) - - # Check if we have any directories - if [[ -z "$DIRS" ]]; then - echo "No directories found." - printf "matrix=[]\n" >> "$GITHUB_OUTPUT" - printf "has_changes=false\n" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "$GET_APP_NAME" == "true" ]]; then - echo "Generating object matrix with app names" - MATRIX="[" - FIRST=true - while read -r DIR; do - if [[ -n "$APP_NAME_PREFIX" ]]; then - APP_NAME="${APP_NAME_PREFIX}-$(echo "$DIR" | cut -d'/' -f2)" - else - APP_NAME="$(echo "$DIR" | cut -d'/' -f2)" - fi - ENTRY="{\"name\":\"$APP_NAME\",\"working_dir\":\"$DIR\"}" - if $FIRST; then - MATRIX+="$ENTRY" - FIRST=false - else - MATRIX+=",$ENTRY" - fi - done <<< "$DIRS" - MATRIX+="]" - else - # Default: return just an array of paths - MATRIX=$(echo "$DIRS" | jq -Rc . | jq -sc .) - fi - - echo "Changed directories matrix: $MATRIX" - printf "matrix=%s\n" "$MATRIX" >> "$GITHUB_OUTPUT" - printf "has_changes=true\n" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/dispatch-helm.yml b/.github/workflows/dispatch-helm.yml index 944c5470..f48c94a9 100644 --- a/.github/workflows/dispatch-helm.yml +++ b/.github/workflows/dispatch-helm.yml @@ -88,7 +88,7 @@ on: runner_type: description: 'GitHub runner type to use' type: string - default: 'ubuntu-latest' + default: 'blacksmith-4vcpu-ubuntu-2404' secrets: helm_repo_token: description: 'GitHub token with access to Helm repository (needs repo scope)' @@ -100,7 +100,7 @@ jobs: runs-on: ${{ inputs.runner_type }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/frontend-pr-analysis.yml b/.github/workflows/frontend-pr-analysis.yml index aebf2af5..e6fae6ce 100644 --- a/.github/workflows/frontend-pr-analysis.yml +++ b/.github/workflows/frontend-pr-analysis.yml @@ -228,7 +228,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node_version }} cache: ${{ inputs.package_manager }} @@ -269,7 +269,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node_version }} cache: ${{ inputs.package_manager }} @@ -305,7 +305,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node_version }} cache: ${{ inputs.package_manager }} @@ -346,7 +346,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node_version }} cache: ${{ inputs.package_manager }} @@ -371,7 +371,7 @@ jobs: esac - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-${{ matrix.app.name }} path: | @@ -395,7 +395,7 @@ jobs: uses: actions/checkout@v6 - name: Download coverage artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: coverage-${{ matrix.app.name }} path: ${{ matrix.app.working_dir }}/coverage @@ -425,7 +425,7 @@ jobs: - name: Post coverage comment if: github.event_name == 'pull_request' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.MANAGE_TOKEN || github.token }} script: | @@ -515,7 +515,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node_version }} cache: ${{ inputs.package_manager }} diff --git a/.github/workflows/gitops-update.yml b/.github/workflows/gitops-update.yml index 5bad6822..024a8ee8 100644 --- a/.github/workflows/gitops-update.yml +++ b/.github/workflows/gitops-update.yml @@ -75,7 +75,7 @@ jobs: steps: - name: Log in to Docker Hub if: ${{ inputs.enable_docker_login }} - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -157,7 +157,7 @@ jobs: - name: Download GitOps tag artifacts (pattern-based) id: download-pattern - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: ${{ steps.setup.outputs.artifact_pattern }} path: .gitops-tags @@ -168,7 +168,7 @@ jobs: - name: Fallback to legacy artifact name if: steps.download-pattern.outcome == 'failure' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: gitops-tags path: .gitops-tags @@ -401,7 +401,11 @@ jobs: echo "has_sync_targets=false" >> "$GITHUB_OUTPUT" fi - echo "updated_count=$(echo -e "$UPDATED_FILES" | grep -c -v '^$' || echo 0)" >> "$GITHUB_OUTPUT" + COUNT=0 + if [[ -n "$UPDATED_FILES" ]]; then + COUNT=$(echo -e "$UPDATED_FILES" | grep -c -v '^$' || true) + fi + echo "updated_count=$COUNT" >> "$GITHUB_OUTPUT" - name: Show git diff shell: bash @@ -414,7 +418,7 @@ jobs: git diff - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@v7 with: gpg_private_key: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY }} passphrase: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY_PASSWORD }} diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index bfd871ac..1ba3ad27 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -101,7 +101,7 @@ jobs: - name: Upload coverage artifact if: inputs.enable_coverage_comment && matrix.os == 'ubuntu-latest' && matrix.go == inputs.go_version_lint - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: coverage-report path: coverage.txt @@ -121,7 +121,7 @@ jobs: uses: actions/checkout@v6 - name: Download coverage artifact - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: coverage-report @@ -195,7 +195,7 @@ jobs: done - name: Upload build artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: binaries path: build/ @@ -265,7 +265,7 @@ jobs: test -f LICENSE - name: Validate Markdown links - uses: gaurav-nelson/github-action-markdown-link-check@v1 + uses: tcort/github-action-markdown-link-check@v1 with: use-quiet-mode: 'yes' config-file: '.github/markdown-link-check-config.json' diff --git a/.github/workflows/go-fuzz.yml b/.github/workflows/go-fuzz.yml new file mode 100644 index 00000000..0204e8a0 --- /dev/null +++ b/.github/workflows/go-fuzz.yml @@ -0,0 +1,109 @@ +name: "Go Fuzz Tests" + +# Reusable workflow for Go fuzz testing +# Runs fuzz tests and uploads failure artifacts for analysis + +on: + workflow_call: + inputs: + runner_type: + description: 'GitHub runner type to use' + type: string + default: 'blacksmith-4vcpu-ubuntu-2404' + go_version: + description: 'Go version to use' + type: string + default: '1.25' + fuzz_command: + description: 'Command to run fuzz tests' + type: string + default: 'make fuzz-ci' + fuzz_artifacts_path: + description: 'Path pattern for fuzz failure artifacts' + type: string + default: 'tests/fuzz/**/testdata/fuzz/' + artifacts_retention_days: + description: 'Number of days to retain fuzz failure artifacts' + type: number + default: 7 + timeout_minutes: + description: 'Maximum job duration in minutes (safety net for unbounded fuzz)' + type: number + default: 30 + dry_run: + description: 'Preview configuration without running fuzz tests' + required: false + type: boolean + default: false + workflow_dispatch: + inputs: + runner_type: + description: 'GitHub runner type to use' + type: string + default: 'blacksmith-4vcpu-ubuntu-2404' + go_version: + description: 'Go version to use' + type: string + default: '1.25' + fuzz_command: + description: 'Command to run fuzz tests' + type: string + default: 'make fuzz-ci' + fuzz_artifacts_path: + description: 'Path pattern for fuzz failure artifacts' + type: string + default: 'tests/fuzz/**/testdata/fuzz/' + artifacts_retention_days: + description: 'Number of days to retain fuzz failure artifacts' + type: number + default: 7 + timeout_minutes: + description: 'Maximum job duration in minutes (safety net for unbounded fuzz)' + type: number + default: 30 + dry_run: + description: 'Preview configuration without running fuzz tests' + type: boolean + default: false + +permissions: + contents: read + +jobs: + fuzz: + name: Fuzz Tests + runs-on: ${{ inputs.runner_type }} + timeout-minutes: ${{ inputs.timeout_minutes }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ${{ inputs.go_version }} + + - name: Dry run summary + if: ${{ inputs.dry_run }} + run: | + echo "::notice::DRY RUN — no fuzz tests will be executed" + echo " runner : ${{ inputs.runner_type }}" + echo " go_version : ${{ inputs.go_version }}" + echo " fuzz_command: ${{ inputs.fuzz_command }}" + echo " artifacts : ${{ inputs.fuzz_artifacts_path }}" + echo " retention : ${{ inputs.artifacts_retention_days }} days" + echo " timeout : ${{ inputs.timeout_minutes }} minutes" + + - name: Run Fuzz Tests + id: fuzz + if: ${{ !inputs.dry_run }} + run: ${{ inputs.fuzz_command }} + + - name: Upload Fuzz Artifacts + if: ${{ !inputs.dry_run && steps.fuzz.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: fuzz-failures + path: ${{ inputs.fuzz_artifacts_path }} + retention-days: ${{ inputs.artifacts_retention_days }} diff --git a/.github/workflows/go-pr-analysis.yml b/.github/workflows/go-pr-analysis.yml index d839c625..fa00ced1 100644 --- a/.github/workflows/go-pr-analysis.yml +++ b/.github/workflows/go-pr-analysis.yml @@ -68,6 +68,22 @@ on: description: 'GOPRIVATE pattern for private Go modules (e.g., github.com/LerianStudio/*)' type: string default: '' + enable_integration_tests: + description: 'Enable integration tests (requires make test-integration target)' + type: boolean + default: false + integration_test_command: + description: 'Command to run integration tests' + type: string + default: 'make test-integration' + enable_test_determinism: + description: 'Enable test determinism check (runs tests multiple times with shuffle)' + type: boolean + default: false + test_determinism_runs: + description: 'Number of times to run tests for determinism check' + type: number + default: 3 permissions: contents: read @@ -317,7 +333,7 @@ jobs: - name: Run Gosec for SARIF id: gosec-sarif - uses: securego/gosec@v2.22.11 + uses: securego/gosec@v2.24.7 with: args: -no-fail -fmt sarif -out gosec-${{ matrix.app.name }}.sarif ./${{ matrix.app.working_dir }}/... @@ -460,7 +476,7 @@ jobs: echo "$PACKAGES" | xargs go test -v -race -coverprofile=coverage.txt -covermode=atomic - name: Upload coverage artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: coverage-${{ matrix.app.name }} path: ${{ matrix.app.working_dir }}/coverage.txt @@ -496,7 +512,7 @@ jobs: GOPRIVATE: ${{ inputs.go_private_modules }} - name: Download coverage artifact - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: coverage-${{ matrix.app.name }} path: ${{ matrix.app.working_dir }} @@ -605,7 +621,7 @@ jobs: fi - name: Upload coverage report - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: coverage-report-${{ matrix.app.name }} path: | @@ -769,6 +785,90 @@ jobs: working-directory: ${{ matrix.app.working_dir }} run: go build -v ./... + # ============================================ + # INTEGRATION TESTS + # ============================================ + integration-tests: + name: Integration Tests (${{ matrix.app.name }}) + needs: detect-changes + if: needs.detect-changes.outputs.has_changes == 'true' && inputs.enable_integration_tests + runs-on: ${{ inputs.runner_type }} + strategy: + fail-fast: false + matrix: + app: ${{ fromJson(needs.detect-changes.outputs.matrix) }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ inputs.go_version }} + cache: true + + - name: Configure private Go modules + if: inputs.go_private_modules != '' + run: | + git config --global url."https://${{ secrets.MANAGE_TOKEN }}@github.com/".insteadOf "https://github.com/" + env: + GOPRIVATE: ${{ inputs.go_private_modules }} + + - name: Run integration tests + working-directory: ${{ matrix.app.working_dir }} + run: ${{ inputs.integration_test_command }} + + # ============================================ + # TEST DETERMINISM + # ============================================ + test-determinism: + name: Test Determinism (${{ matrix.app.name }}) + needs: detect-changes + if: needs.detect-changes.outputs.has_changes == 'true' && inputs.enable_test_determinism + runs-on: ${{ inputs.runner_type }} + strategy: + fail-fast: false + matrix: + app: ${{ fromJson(needs.detect-changes.outputs.matrix) }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ inputs.go_version }} + cache: true + + - name: Configure private Go modules + if: inputs.go_private_modules != '' + run: | + git config --global url."https://${{ secrets.MANAGE_TOKEN }}@github.com/".insteadOf "https://github.com/" + env: + GOPRIVATE: ${{ inputs.go_private_modules }} + + - name: Run tests ${{ inputs.test_determinism_runs }} times + working-directory: ${{ matrix.app.working_dir }} + run: | + echo "Running tests ${{ inputs.test_determinism_runs }} times to verify determinism..." + + # Always use go test with shuffle for determinism, regardless of Makefile + # The tests job validates that tests pass; this job validates they're deterministic + PACKAGES=$(go list ./... | awk '!/\/tests($|\/)/' | awk '!/\/api($|\/)/') + if [[ -z "$PACKAGES" ]]; then + echo "No packages found after filtering /tests/ and /api/" + exit 0 + fi + + for i in $(seq 1 ${{ inputs.test_determinism_runs }}); do + echo "Run $i/${{ inputs.test_determinism_runs }}" + if ! echo $PACKAGES | xargs go test -count=1 -shuffle=on; then + echo "Tests failed on run $i" + exit 1 + fi + done + echo "All ${{ inputs.test_determinism_runs }} test runs passed (deterministic)" + # ============================================ # NO CHANGES DETECTED # ============================================ @@ -788,12 +888,12 @@ jobs: # ============================================ notify: name: Notify - needs: [detect-changes, lint, security, tests, build] + needs: [detect-changes, lint, security, tests, build, integration-tests, test-determinism] if: always() && needs.detect-changes.outputs.has_changes == 'true' uses: ./.github/workflows/slack-notify.yml with: - status: ${{ (needs.lint.result == 'failure' || needs.security.result == 'failure' || needs.tests.result == 'failure' || needs.build.result == 'failure') && 'failure' || 'success' }} + status: ${{ (needs.lint.result == 'failure' || needs.security.result == 'failure' || needs.tests.result == 'failure' || needs.build.result == 'failure' || needs.integration-tests.result == 'failure' || needs.test-determinism.result == 'failure') && 'failure' || 'success' }} workflow_name: "Go PR Analysis" - failed_jobs: ${{ needs.lint.result == 'failure' && 'Lint, ' || '' }}${{ needs.security.result == 'failure' && 'Security, ' || '' }}${{ needs.tests.result == 'failure' && 'Tests, ' || '' }}${{ needs.build.result == 'failure' && 'Build' || '' }} + failed_jobs: ${{ needs.lint.result == 'failure' && 'Lint, ' || '' }}${{ needs.security.result == 'failure' && 'Security, ' || '' }}${{ needs.tests.result == 'failure' && 'Tests, ' || '' }}${{ needs.build.result == 'failure' && 'Build, ' || '' }}${{ needs.integration-tests.result == 'failure' && 'Integration Tests, ' || '' }}${{ needs.test-determinism.result == 'failure' && 'Test Determinism' || '' }} secrets: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml index fa2735b2..cf81a63d 100644 --- a/.github/workflows/go-release.yml +++ b/.github/workflows/go-release.yml @@ -97,7 +97,7 @@ jobs: run: ${{ inputs.test_cmd }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: distribution: ${{ inputs.goreleaser_distribution }} version: ${{ inputs.goreleaser_version }} @@ -147,10 +147,10 @@ jobs: uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ inputs.docker_registry }} username: ${{ secrets.DOCKER_USERNAME || github.actor }} @@ -158,13 +158,13 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ inputs.docker_registry }}/${{ github.repository }} tags: ${{ inputs.docker_tags }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: ${{ inputs.docker_platforms }} diff --git a/.github/workflows/go-security.yml b/.github/workflows/go-security.yml index e35a7253..79288dce 100644 --- a/.github/workflows/go-security.yml +++ b/.github/workflows/go-security.yml @@ -165,7 +165,7 @@ jobs: uses: actions/checkout@v6 - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.34.1 + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: 'fs' scan-ref: '.' @@ -227,7 +227,7 @@ jobs: cat licenses.txt - name: Upload license report - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: license-report path: licenses.txt @@ -255,7 +255,7 @@ jobs: output-file: sbom.spdx.json - name: Upload SBOM - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: sbom path: sbom.spdx.json diff --git a/.github/workflows/gptchangelog.yml b/.github/workflows/gptchangelog.yml index 0334213c..7acc6177 100644 --- a/.github/workflows/gptchangelog.yml +++ b/.github/workflows/gptchangelog.yml @@ -27,6 +27,11 @@ on: type: string required: false default: '' + shared_paths: + description: 'Newline-separated path patterns (e.g., "go.mod\ngo.sum") that trigger a build for ALL components when matched by any changed file.' + type: string + required: false + default: '' path_level: description: 'Limits the path to the first N segments (e.g., 2 -> "charts/agent")' type: string @@ -57,7 +62,7 @@ jobs: is_stable: ${{ steps.check-tag.outputs.is_stable }} steps: - name: Checkout for branch check - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -73,11 +78,11 @@ jobs: # Triggered by workflow_run - find latest stable tags echo "📌 Triggered by workflow_run, finding latest stable tags..." git fetch --tags - + # Get tags created in the last hour (recent release) LATEST_TAGS=$(git tag --sort=-creatordate | head -20) echo "📌 Recent tags: $LATEST_TAGS" - + # Find first stable tag (no beta/rc/alpha) TAG_NAME="" for tag in $LATEST_TAGS; do @@ -87,46 +92,46 @@ jobs: break fi done - + if [ -z "$TAG_NAME" ]; then echo "⚠️ No stable tags found" - echo "is_stable=false" >> $GITHUB_OUTPUT + echo "is_stable=false" >> "$GITHUB_OUTPUT" exit 0 fi fi - + echo "📌 Processing tag: $TAG_NAME" - + # Get the commit SHA for this tag TAG_COMMIT=$(git rev-list -n 1 "$TAG_NAME" 2>/dev/null || echo "") if [ -z "$TAG_COMMIT" ]; then echo "❌ Could not find commit for tag: $TAG_NAME" - echo "is_stable=false" >> $GITHUB_OUTPUT + echo "is_stable=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "📌 Tag commit: $TAG_COMMIT" - + # Check if this commit is on main branch DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name') echo "📌 Default branch: $DEFAULT_BRANCH" - + if git merge-base --is-ancestor "$TAG_COMMIT" "origin/$DEFAULT_BRANCH" 2>/dev/null; then echo "✅ Tag commit is on $DEFAULT_BRANCH branch" else echo "❌ Tag commit is NOT on $DEFAULT_BRANCH branch - skipping changelog" - echo "is_stable=false" >> $GITHUB_OUTPUT + echo "is_stable=false" >> "$GITHUB_OUTPUT" exit 0 fi - + # Check if this is a prerelease tag (beta, rc, alpha) if [[ "$TAG_NAME" =~ -(beta|rc|alpha|dev|snapshot) ]]; then - echo "is_stable=false" >> $GITHUB_OUTPUT + echo "is_stable=false" >> "$GITHUB_OUTPUT" echo "⚠️ Prerelease tag detected: $TAG_NAME" if [ "${{ inputs.stable_releases_only }}" == "true" ]; then echo "🛑 stable_releases_only=true, skipping changelog generation" fi else - echo "is_stable=true" >> $GITHUB_OUTPUT + echo "is_stable=true" >> "$GITHUB_OUTPUT" echo "✅ Stable release tag on $DEFAULT_BRANCH: $TAG_NAME" fi env: @@ -134,60 +139,62 @@ jobs: - name: Checkout repository if: steps.check-tag.outputs.is_stable == 'true' || inputs.stable_releases_only == false - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Get changed paths (monorepo) if: (steps.check-tag.outputs.is_stable == 'true' || inputs.stable_releases_only == false) && inputs.filter_paths != '' id: changed-paths - uses: LerianStudio/github-actions-changed-paths@main + uses: LerianStudio/github-actions-shared-workflows/src/config/changed-paths@develop with: - filter_paths: ${{ inputs.filter_paths }} - path_level: ${{ inputs.path_level }} - get_app_name: 'true' + filter-paths: ${{ inputs.filter_paths }} + shared-paths: ${{ inputs.shared_paths }} + path-level: ${{ inputs.path_level }} + get-app-name: 'true' - name: Set matrix id: set-matrix run: | # Skip if stable_releases_only is enabled and tag is not stable if [ "${{ inputs.stable_releases_only }}" == "true" ] && [ "${{ steps.check-tag.outputs.is_stable }}" != "true" ]; then - echo "matrix=[]" >> $GITHUB_OUTPUT - echo "has_changes=false" >> $GITHUB_OUTPUT + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "🛑 Skipping: prerelease tag with stable_releases_only=true" exit 0 fi - + # Detect trigger type: workflow_run or push:tags if [[ "$GITHUB_REF" != refs/tags/* ]]; then # Triggered by workflow_run - find apps from recent stable tags echo "📌 Triggered by workflow_run, finding apps from recent stable tags..." git fetch --tags - + # Get filter_paths as array FILTER_PATHS="${{ inputs.filter_paths }}" - + if [ -z "$FILTER_PATHS" ]; then # Single app mode APP_NAME="${{ github.event.repository.name }}" - echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> $GITHUB_OUTPUT - echo "has_changes=true" >> $GITHUB_OUTPUT + echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" echo "📦 Single app mode: ${APP_NAME}" else # Monorepo mode - find apps with recent stable tags MATRIX="[" FIRST=true - + # Parse filter_paths to get app names while IFS= read -r path; do [ -z "$path" ] && continue APP_NAME=$(basename "$path") - + # Check if this app has a recent stable tag # Escape regex metacharacters in app name for grep + # shellcheck disable=SC2001,SC2016 APP_NAME_ESCAPED=$(echo "$APP_NAME" | sed 's/[.[\*^$()+?{}|\\]/\\&/g') LATEST_TAG=$(git tag --sort=-creatordate | grep "^${APP_NAME_ESCAPED}-v" | grep -v -e "-beta" -e "-rc" -e "-alpha" -e "-dev" -e "-snapshot" | head -1) - + if [ -n "$LATEST_TAG" ]; then echo "📦 Found stable tag for $APP_NAME: $LATEST_TAG" if [ "$FIRST" = true ]; then @@ -200,16 +207,16 @@ jobs: echo "⚠️ No stable tag found for $APP_NAME" fi done <<< "$FILTER_PATHS" - + MATRIX="$MATRIX]" - + if [ "$MATRIX" = "[]" ]; then - echo "matrix=[]" >> $GITHUB_OUTPUT - echo "has_changes=false" >> $GITHUB_OUTPUT + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "⚠️ No apps with stable tags found" else - echo "matrix=$MATRIX" >> $GITHUB_OUTPUT - echo "has_changes=true" >> $GITHUB_OUTPUT + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" echo "📦 Monorepo mode - found apps: $MATRIX" fi fi @@ -218,18 +225,18 @@ jobs: if [ -z "${{ inputs.filter_paths }}" ]; then # Single app mode - generate changelog from root APP_NAME="${{ github.event.repository.name }}" - echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> $GITHUB_OUTPUT - echo "has_changes=true" >> $GITHUB_OUTPUT + echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" echo "📦 Single app mode: ${APP_NAME}" else MATRIX='${{ steps.changed-paths.outputs.matrix }}' if [ "$MATRIX" == "[]" ] || [ -z "$MATRIX" ]; then - echo "matrix=[]" >> $GITHUB_OUTPUT - echo "has_changes=false" >> $GITHUB_OUTPUT + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "⚠️ No changes detected in filter_paths" else - echo "matrix=$MATRIX" >> $GITHUB_OUTPUT - echo "has_changes=true" >> $GITHUB_OUTPUT + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" echo "📦 Monorepo mode - detected changes: $MATRIX" fi fi @@ -245,14 +252,14 @@ jobs: steps: - name: Create GitHub App Token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} private-key: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_PRIVATE_KEY }} - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} @@ -260,6 +267,7 @@ jobs: - name: Sync with remote ref run: | # Handle both branch and tag triggers + # shellcheck disable=SC2193 if [[ "${{ github.ref }}" == refs/tags/* ]]; then echo "📌 Triggered by tag: ${{ github.ref_name }}" # For tags, checkout already positioned us at the correct commit @@ -271,7 +279,7 @@ jobs: fi - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@v7 id: import_gpg with: gpg_private_key: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY }} @@ -286,28 +294,29 @@ jobs: id: generate run: | git fetch --tags --force - + MATRIX='${{ needs.prepare.outputs.matrix }}' REPO_URL="https://github.com/${{ github.repository }}" - + echo "📦 Processing apps from matrix: $MATRIX" - + # Initialize files - > /tmp/apps_updated.txt - + : > /tmp/apps_updated.txt + # Parse the matrix JSON and iterate through each app # Using process substitution to avoid subshell issues with file writes while read -r APP; do APP_NAME=$(echo "$APP" | jq -r '.name') WORKING_DIR=$(echo "$APP" | jq -r '.working_dir') - + echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "📝 Processing: $APP_NAME (dir: $WORKING_DIR)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - + # Determine tag pattern based on app type (monorepo vs single-app) # Escape regex metacharacters in app name for grep patterns + # shellcheck disable=SC2001,SC2016 APP_NAME_ESCAPED=$(echo "$APP_NAME" | sed 's/[.[\*^$()+?{}|\\]/\\&/g') if [ "$WORKING_DIR" != "." ]; then # Monorepo: tags are prefixed with app name (e.g., auth-v1.0.0) @@ -318,28 +327,28 @@ jobs: TAG_PATTERN="v*" TAG_GREP_PATTERN="^v" fi - + echo "🔍 Looking for tags matching: $TAG_PATTERN" - + # Find the latest STABLE tag for this app (exclude beta/rc/alpha) LAST_TAG=$(git tag --sort=-version:refname | grep "$TAG_GREP_PATTERN" | grep -v -e "-beta" -e "-rc" -e "-alpha" -e "-dev" -e "-snapshot" | head -1) - + if [ -z "$LAST_TAG" ]; then echo "⚠️ No stable tag found for $APP_NAME - skipping" continue fi - + echo "📌 Latest stable tag: $LAST_TAG" - + # Verify tag exists if ! git rev-parse "$LAST_TAG" >/dev/null 2>&1; then echo "❌ Tag $LAST_TAG does not exist - skipping" continue fi - + # Find previous stable tag for compare link PREV_TAG=$(git tag --sort=-version:refname | grep "$TAG_GREP_PATTERN" | grep -v -e "-beta" -e "-rc" -e "-alpha" -e "-dev" -e "-snapshot" | sed -n '2p') - + if [ -n "$PREV_TAG" ]; then SINCE="$PREV_TAG" echo "🟢 Range: $PREV_TAG → $LAST_TAG" @@ -348,8 +357,9 @@ jobs: SINCE=$(git rev-list --max-parents=0 HEAD) echo "🟡 First stable release - Range: first commit → $LAST_TAG" fi - + # Extract version from tag (handles both monorepo and single-app formats) + # shellcheck disable=SC2001 if [ "$WORKING_DIR" != "." ]; then # Monorepo: auth-v1.0.0 -> 1.0.0 VERSION=$(echo "$LAST_TAG" | sed 's/.*-v//') @@ -357,32 +367,32 @@ jobs: # Single-app: v1.0.0 -> 1.0.0 VERSION=$(echo "$LAST_TAG" | sed 's/^v//') fi - + # Generate changelog using gptchangelog with path filter TEMP_CHANGELOG=$(mktemp) TEMP_COMMITS=$(mktemp) - + # Get commits that touched this app's path (filtered by path) if [ "$WORKING_DIR" != "." ]; then git log --oneline "$SINCE".."$LAST_TAG" -- "$WORKING_DIR" > "$TEMP_COMMITS" 2>/dev/null || true else git log --oneline "$SINCE".."$LAST_TAG" > "$TEMP_COMMITS" 2>/dev/null || true fi - + COMMIT_COUNT=$(wc -l < "$TEMP_COMMITS" | tr -d ' ') echo "📊 Found $COMMIT_COUNT commits for $APP_NAME" - + if [ "$COMMIT_COUNT" -eq 0 ]; then echo "⚠️ No commits found for $APP_NAME in range - skipping" rm -f "$TEMP_CHANGELOG" "$TEMP_COMMITS" continue fi - + # Get detailed commit messages for this app only COMMITS_TEXT=$(cat "$TEMP_COMMITS") echo "📝 Commits for $APP_NAME:" echo "$COMMITS_TEXT" - + # Get unique contributors (GitHub usernames) for this app # Try to extract GitHub username from email (format: user@users.noreply.github.com or id+username@users.noreply.github.com) if [ "$WORKING_DIR" != "." ]; then @@ -390,7 +400,7 @@ jobs: else RAW_EMAILS=$(git log "$SINCE".."$LAST_TAG" --format='%ae' 2>/dev/null | sort -u) fi - + # Collect unique usernames (same user may have multiple emails) USERNAMES_FILE=$(mktemp) for EMAIL in $RAW_EMAILS; do @@ -411,12 +421,12 @@ jobs: CONTRIBUTORS=$(sort -u "$USERNAMES_FILE" | sed 's/^/@/' | tr '\n' ', ' | sed 's/, $//') rm -f "$USERNAMES_FILE" echo "👥 Contributors: $CONTRIBUTORS" - + # Call OpenRouter API with filtered commits # Build prompt and escape for JSON - be very strict about component name PROMPT="Generate a changelog for ${APP_NAME} ONLY. Commits: ${COMMITS_TEXT} --- STRICT RULES: 1) NEVER mention other components - only ${APP_NAME}. 2) Use bullet points. 3) Group by Features, Fixes, Improvements (skip empty sections). 4) No markdown headers. 5) Max 5 bullet points. 6) End with Contributors: ${CONTRIBUTORS}" ESCAPED_PROMPT=$(echo "$PROMPT" | jq -Rs .) - + # Call OpenRouter API (OpenAI-compatible) with timeout and error handling HTTP_CODE=$(curl -s -w "%{http_code}" --max-time 60 --connect-timeout 10 -o /tmp/api_response.json \ https://openrouter.ai/api/v1/chat/completions \ @@ -430,7 +440,7 @@ jobs: \"temperature\": 0.3, \"max_tokens\": 1000 }") - + # Check for HTTP errors if [ "$HTTP_CODE" -ge 400 ]; then echo "⚠️ API returned HTTP $HTTP_CODE for $APP_NAME - skipping" @@ -438,37 +448,37 @@ jobs: rm -f "$TEMP_CHANGELOG" "$TEMP_COMMITS" /tmp/api_response.json continue fi - + RESPONSE=$(cat /tmp/api_response.json) rm -f /tmp/api_response.json - + # Extract content from response CONTENT=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // empty') - + if [ -z "$CONTENT" ]; then echo "⚠️ No content generated for $APP_NAME" echo "API Response: $RESPONSE" rm -f "$TEMP_CHANGELOG" "$TEMP_COMMITS" continue fi - + # Clean up any markdown code blocks CONTENT=$(echo "$CONTENT" | sed '/^```/d') - + # Determine the changelog path if [ "$WORKING_DIR" != "." ]; then CHANGELOG_PATH="${WORKING_DIR}/CHANGELOG.md" else CHANGELOG_PATH="CHANGELOG.md" fi - + # Build compare link if [ -n "$PREV_TAG" ]; then COMPARE_LINK="[Compare changes](${REPO_URL}/compare/${PREV_TAG}...${LAST_TAG})" else COMPARE_LINK="[View all changes](${REPO_URL}/commits/${LAST_TAG})" fi - + # Append new version to changelog (keep existing entries) if [ -f "$CHANGELOG_PATH" ]; then # Extract existing entries (everything after first ## header, excluding the title) @@ -482,7 +492,7 @@ jobs: EXISTING_CONTENT="" echo "📜 Creating new changelog" fi - + # Build changelog with new entry at top, existing entries below { echo "# ${APP_NAME^} Changelog" @@ -500,12 +510,12 @@ jobs: fi echo "" } > "$CHANGELOG_PATH" - + echo "📄 Updated: $CHANGELOG_PATH" - + # Track which apps were updated echo "${APP_NAME}:v${VERSION}:${WORKING_DIR}" >> /tmp/apps_updated.txt - + # Update GitHub Release notes { echo "## ${APP_NAME^} v${VERSION}" @@ -514,22 +524,22 @@ jobs: echo "" echo "$COMPARE_LINK" } > /tmp/app_release_notes.md - + gh release edit "$LAST_TAG" --notes-file /tmp/app_release_notes.md || \ echo "⚠️ Could not update release for $LAST_TAG" - + echo "✅ Processed $APP_NAME" rm -f "$TEMP_CHANGELOG" "$TEMP_COMMITS" done < <(echo "$MATRIX" | jq -c '.[]') - + # Output results if [ -s /tmp/apps_updated.txt ]; then - APPS_LIST=$(cat /tmp/apps_updated.txt 2>/dev/null | cut -d: -f1,2 | tr '\n' ', ' | sed 's/,$//') - echo "apps_updated=$APPS_LIST" >> $GITHUB_OUTPUT + APPS_LIST=$(cut -d: -f1,2 < /tmp/apps_updated.txt | tr '\n' ', ' | sed 's/,$//') + echo "apps_updated=$APPS_LIST" >> "$GITHUB_OUTPUT" echo "✅ Per-app changelogs created for: $APPS_LIST" else echo "⚠️ No changelog content generated" - echo "apps_updated=" >> $GITHUB_OUTPUT + echo "apps_updated=" >> "$GITHUB_OUTPUT" fi env: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} @@ -540,7 +550,7 @@ jobs: echo "📄 Generated per-app CHANGELOGs:" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [ -f /tmp/apps_updated.txt ]; then - while IFS=: read -r APP_NAME VERSION WORKING_DIR; do + while IFS=: read -r APP_NAME _VERSION WORKING_DIR; do if [ "$WORKING_DIR" != "." ]; then CHANGELOG_PATH="${WORKING_DIR}/CHANGELOG.md" else @@ -559,6 +569,7 @@ jobs: if: steps.generate.outputs.apps_updated != '' run: | # Determine base branch - use default branch for tag triggers + # shellcheck disable=SC2193 if [[ "${{ github.ref }}" == refs/tags/* ]]; then # For tags, get the default branch from the repo BASE_BRANCH=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name') @@ -567,17 +578,17 @@ jobs: BASE_BRANCH="${GITHUB_REF##*/}" echo "📌 Triggered by branch: $BASE_BRANCH" fi - + TIMESTAMP=$(date +%Y%m%d%H%M%S) BRANCH_NAME="release/update-changelog-${TIMESTAMP}" APPS_UPDATED="${{ steps.generate.outputs.apps_updated }}" - + echo "📌 Creating branch: $BRANCH_NAME" git checkout -b "$BRANCH_NAME" - + # Add all per-app CHANGELOG files if [ -f /tmp/apps_updated.txt ]; then - while IFS=: read -r APP_NAME VERSION WORKING_DIR; do + while IFS=: read -r _APP_NAME _VERSION WORKING_DIR; do if [ "$WORKING_DIR" != "." ]; then CHANGELOG_PATH="${WORKING_DIR}/CHANGELOG.md" else @@ -587,7 +598,7 @@ jobs: echo "📄 Added: $CHANGELOG_PATH" done < /tmp/apps_updated.txt fi - + if ! git diff --cached --quiet; then git commit -S -m "chore(release): Update CHANGELOGs for ${APPS_UPDATED} [skip ci]" echo "✅ CHANGELOGs committed" @@ -595,13 +606,13 @@ jobs: echo "⚠️ No changes to commit" exit 0 fi - + # Merge base branch to resolve conflicts git fetch origin "$BASE_BRANCH" git merge -X ours origin/"$BASE_BRANCH" --no-ff -m "Merge $BASE_BRANCH into ${BRANCH_NAME} [skip ci]" || { # Re-add changelog files after conflict resolution if [ -f /tmp/apps_updated.txt ]; then - while IFS=: read -r APP_NAME VERSION WORKING_DIR; do + while IFS=: read -r _APP_NAME _VERSION WORKING_DIR; do if [ "$WORKING_DIR" != "." ]; then CHANGELOG_PATH="${WORKING_DIR}/CHANGELOG.md" else @@ -613,10 +624,10 @@ jobs: fi git commit -S -m "resolve conflict using ours strategy [skip ci]" || true } - + # Push and create PR git push --force-with-lease origin "$BRANCH_NAME" - + if ! gh pr view "$BRANCH_NAME" --base "$BASE_BRANCH" > /dev/null 2>&1; then gh pr create \ --title "chore(release): Update CHANGELOGs [skip ci]" \ @@ -639,7 +650,7 @@ jobs: else echo "⚠️ PR already exists" fi - + # Auto-merge if possible (capture stderr for failure details) gh pr merge --merge --delete-branch 2>&1 || echo "⚠️ Could not auto-merge PR (check above for details)" env: @@ -650,28 +661,27 @@ jobs: if: steps.generate.outputs.apps_updated != '' run: | DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name') - SYNC_PR_URL="" - + # Only sync to develop branch (release-candidate gets updated from develop) TARGET_BRANCH="develop" - + # Check if develop branch exists if ! git ls-remote --heads origin develop | grep -q develop; then echo "⚠️ No develop branch found - skipping sync" - echo "sync_pr=" >> $GITHUB_OUTPUT + echo "sync_pr=" >> "$GITHUB_OUTPUT" exit 0 fi - + echo "📌 Syncing $DEFAULT_BRANCH → $TARGET_BRANCH" - + # Check if PR already exists from main to develop (only open PRs) EXISTING_PR=$(gh pr list --state open --base "$TARGET_BRANCH" --head "$DEFAULT_BRANCH" --json number -q '.[0].number' 2>/dev/null || true) if [ -n "$EXISTING_PR" ]; then echo "⚠️ PR #$EXISTING_PR already exists for $DEFAULT_BRANCH → $TARGET_BRANCH" - echo "sync_pr=" >> $GITHUB_OUTPUT + echo "sync_pr=" >> "$GITHUB_OUTPUT" exit 0 fi - + # Create PR directly from main to develop PR_URL=$(gh pr create \ --title "chore: sync $DEFAULT_BRANCH to $TARGET_BRANCH [skip ci]" \ @@ -687,12 +697,12 @@ jobs: --base "$TARGET_BRANCH" \ --head "$DEFAULT_BRANCH" 2>&1) || { echo "⚠️ Could not create PR: $PR_URL" - echo "sync_pr=" >> $GITHUB_OUTPUT + echo "sync_pr=" >> "$GITHUB_OUTPUT" exit 0 } - + echo "✅ Created PR: $PR_URL" - echo "sync_pr=$PR_URL" >> $GITHUB_OUTPUT + echo "sync_pr=$PR_URL" >> "$GITHUB_OUTPUT" env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -720,7 +730,7 @@ jobs: name: Notify Sync PR needs: [generate_changelog] if: needs.generate_changelog.result == 'success' && needs.generate_changelog.outputs.sync_pr != '' - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Send Slack notification for sync PR uses: slackapi/slack-github-action@v1.24.0 diff --git a/.github/workflows/helm-update-chart.yml b/.github/workflows/helm-update-chart.yml index 9e49f616..dbc3e97e 100644 --- a/.github/workflows/helm-update-chart.yml +++ b/.github/workflows/helm-update-chart.yml @@ -40,7 +40,7 @@ on: runner_type: description: 'GitHub runner type to use' type: string - default: 'ubuntu-latest' + default: 'blacksmith-4vcpu-ubuntu-2404' gpg_sign_commits: description: 'Whether to sign commits with GPG (default: true)' type: boolean @@ -100,7 +100,7 @@ jobs: steps: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v2 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} @@ -148,7 +148,7 @@ jobs: jq -c '.components' /tmp/payload.json > /tmp/components.json - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ steps.app-token.outputs.token }} ref: ${{ inputs.base_branch }} @@ -156,7 +156,7 @@ jobs: - name: Import GPG key if: ${{ inputs.gpg_sign_commits }} - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@v7 with: gpg_private_key: ${{ secrets.GPG_KEY }} passphrase: ${{ secrets.GPG_KEY_PASSWORD }} @@ -181,7 +181,7 @@ jobs: - name: Setup Go if: ${{ inputs.update_readme }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '1.21' cache-dependency-path: ${{ inputs.scripts_path }}/go.mod diff --git a/.github/workflows/labels-sync.yml b/.github/workflows/labels-sync.yml index 99b5c3c0..0b072e89 100644 --- a/.github/workflows/labels-sync.yml +++ b/.github/workflows/labels-sync.yml @@ -18,9 +18,6 @@ on: type: boolean required: false default: false - secrets: - GITHUB_TOKEN: - required: false workflow_dispatch: inputs: config: @@ -44,10 +41,10 @@ permissions: jobs: sync: name: Sync labels to repository - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Sync labels - uses: ./src/labels-sync + uses: LerianStudio/github-actions-shared-workflows/src/config/labels-sync@develop with: github-token: ${{ secrets.GITHUB_TOKEN }} config: ${{ inputs.config || '.github/labels.yml' }} diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 4953a8b7..77910876 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -18,10 +18,27 @@ on: description: 'Paths to monitor for changes (newline separated). If not provided, treats as single app repo' type: string required: false + shared_paths: + description: 'Newline-separated path patterns (e.g., "go.mod\ngo.sum") that trigger a build for ALL components when matched by any changed file.' + type: string + required: false + default: '' path_level: description: 'Directory depth level to extract app name (only used for monorepo)' type: string default: '2' + app_name_prefix: + description: 'Prefix for app names in monorepo (e.g., "midaz" results in "midaz-agent"). Must match build workflow config.' + type: string + default: '' + app_name_overrides: + description: 'Explicit app name mappings in "path:name" format. Must match build workflow config.' + type: string + default: '' + normalize_to_filter: + description: 'Normalize changed paths to their filter path. Recommended for monorepos to match build workflow behavior.' + type: boolean + default: true monorepo_type: description: 'Type of monorepo: "type1" (components in folders) or "type2" (backend in root, frontend in folder)' type: string @@ -46,6 +63,10 @@ on: description: 'Enable Docker image build and vulnerability scanning. Set to false for projects without Dockerfile (e.g., CLI tools)' type: boolean default: true + enable_health_score: + description: 'Enable Docker Hub Health Score compliance checks (non-root user, CVEs, licenses)' + type: boolean + default: true permissions: id-token: write # Required for OIDC authentication @@ -57,55 +78,34 @@ jobs: prepare_matrix: runs-on: ${{ inputs.runner_type }} outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} + matrix: ${{ steps.changed-paths.outputs.matrix }} steps: + # ----------------- Setup ----------------- - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ inputs.docker_registry }} username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Get changed paths (monorepo) - if: inputs.filter_paths != '' + # ----------------- Detect Changes & Build Matrix ----------------- + - name: Get changed paths id: changed-paths - uses: LerianStudio/github-actions-changed-paths@main + uses: LerianStudio/github-actions-shared-workflows/src/config/changed-paths@develop with: - filter_paths: ${{ inputs.filter_paths }} - get_app_name: true - path_level: ${{ inputs.path_level }} - - - name: Set matrix - id: set-matrix - run: | - if [ "${{ inputs.filter_paths }}" = "" ]; then - # Single app mode - echo 'matrix=[{"name": "${{ github.event.repository.name }}", "working_dir": "."}]' >> $GITHUB_OUTPUT - elif [ "${{ inputs.monorepo_type }}" = "type2" ]; then - # Type 2 monorepo: backend in root, frontend in folder - CHANGED_MATRIX='${{ steps.changed-paths.outputs.matrix }}' - - # Process the matrix to handle Type 2 logic - PROCESSED_MATRIX=$(echo "$CHANGED_MATRIX" | jq -c ' - map( - if .working_dir == "${{ inputs.frontend_folder }}" then - # Frontend changes - keep as is - . - elif (.working_dir == ".github" or .working_dir == ".githooks") then - # Ignore .github and .githooks folders - empty - else - # Backend changes - consolidate to root - {"name": "${{ github.event.repository.name }}", "working_dir": "."} - end - ) | unique_by(.working_dir) - ') - - echo "matrix=$PROCESSED_MATRIX" >> $GITHUB_OUTPUT - else - # Type 1 monorepo mode (default) - echo 'matrix=${{ steps.changed-paths.outputs.matrix }}' >> $GITHUB_OUTPUT - fi + filter-paths: ${{ inputs.filter_paths }} + shared-paths: ${{ inputs.shared_paths }} + get-app-name: true + path-level: ${{ inputs.path_level }} + app-name-prefix: ${{ inputs.app_name_prefix }} + app-name-overrides: ${{ inputs.app_name_overrides }} + normalize-to-filter: ${{ inputs.normalize_to_filter }} + ignore-dirs: | + .github + .githooks + fallback-app-name: ${{ github.event.repository.name }} + consolidate-to-root: ${{ inputs.monorepo_type == 'type2' }} + consolidate-keep-dirs: ${{ inputs.frontend_folder }} security_scan: needs: prepare_matrix @@ -122,8 +122,9 @@ jobs: DOCKERFILE_PATH: ${{ matrix.working_dir == '.' && format('./{0}', inputs.dockerfile_name) || format('{0}/{1}', matrix.working_dir, inputs.dockerfile_name) }} steps: + # ----------------- Setup ----------------- - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ inputs.docker_registry }} username: ${{ secrets.DOCKER_USERNAME }} @@ -134,37 +135,21 @@ jobs: - name: Set up Docker Buildx if: inputs.enable_docker_scan - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - # ----------------- Security Scans ----------------- - - name: Trivy Secret Scan - Component (Table Output) - uses: aquasecurity/trivy-action@0.34.1 + # ----------------- Filesystem Scanning (Secrets + Vulnerabilities) ----------------- + - name: Trivy Filesystem Scan + id: fs-scan if: always() + uses: LerianStudio/github-actions-shared-workflows/src/security/trivy-fs-scan@develop with: - scan-type: fs scan-ref: ${{ matrix.working_dir }} - format: table - exit-code: '1' - hide-progress: true - skip-dirs: '.git,node_modules,dist,build,.next,coverage,vendor' - version: 'v0.69.2' - - - name: Trivy Secret Scan - Component (SARIF Output) - uses: aquasecurity/trivy-action@0.34.1 - if: always() - with: - scan-type: fs - scan-ref: ${{ matrix.working_dir }} - format: sarif - output: 'trivy-secret-scan-${{ env.APP_NAME }}.sarif' - exit-code: '0' - hide-progress: true - skip-dirs: '.git,node_modules,dist,build,.next,coverage,vendor' - version: 'v0.69.2' + app-name: ${{ env.APP_NAME }} + # ----------------- Docker Build ----------------- - name: Build Docker Image for Scanning if: always() && inputs.enable_docker_scan - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ${{ inputs.monorepo_type == 'type2' && matrix.working_dir == inputs.frontend_folder && inputs.frontend_folder || '.' }} file: ${{ env.DOCKERFILE_PATH }} @@ -176,227 +161,36 @@ jobs: ${{ secrets.MANAGE_TOKEN && format('github_token={0}', secrets.MANAGE_TOKEN) || '' }} ${{ secrets.NPMRC_TOKEN && format('npmrc=//npm.pkg.github.com/:_authToken={0}', secrets.NPMRC_TOKEN) || '' }} - - name: Trivy Vulnerability Scan - Docker Image (Table Output) + # ----------------- Image Scanning (Vulnerabilities + Licenses) ----------------- + - name: Trivy Image Scan + id: image-scan if: always() && inputs.enable_docker_scan - uses: aquasecurity/trivy-action@0.34.1 + uses: LerianStudio/github-actions-shared-workflows/src/security/trivy-image-scan@develop with: image-ref: '${{ env.DOCKERHUB_ORG }}/${{ env.APP_NAME }}:pr-scan-${{ github.sha }}' - format: 'table' - ignore-unfixed: true - vuln-type: 'os,library' - severity: 'CRITICAL,HIGH' - exit-code: '0' - version: 'v0.69.2' - - - name: Trivy Vulnerability Scan - Docker Image (SARIF Output) - if: always() && inputs.enable_docker_scan - uses: aquasecurity/trivy-action@0.34.1 + app-name: ${{ env.APP_NAME }} + enable-license-scan: ${{ inputs.enable_health_score }} + + # ----------------- Dockerfile Compliance Checks ----------------- + - name: Dockerfile Compliance Checks + id: dockerfile-checks + if: always() && inputs.enable_docker_scan && inputs.enable_health_score + uses: LerianStudio/github-actions-shared-workflows/src/security/dockerfile-checks@develop with: - image-ref: '${{ env.DOCKERHUB_ORG }}/${{ env.APP_NAME }}:pr-scan-${{ github.sha }}' - format: sarif - output: 'trivy-vulnerability-scan-docker-${{ env.APP_NAME }}.sarif' - ignore-unfixed: true - vuln-type: os,library - severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL - exit-code: '0' # Do not fail; gate failures in the table step - version: 'v0.69.2' + dockerfile-path: ${{ env.DOCKERFILE_PATH }} - # ----------------- Filesystem Vulnerability Scan ----------------- - - name: Trivy Vulnerability Scan - Filesystem (JSON Output) - id: fs-vuln-scan - uses: aquasecurity/trivy-action@0.34.1 - if: always() - with: - scan-type: fs - scan-ref: ${{ matrix.working_dir }} - format: json - output: 'trivy-fs-vuln-${{ env.APP_NAME }}.json' - exit-code: '0' - hide-progress: true - skip-dirs: '.git,node_modules,dist,build,.next,coverage,vendor' - version: 'v0.69.2' - - # ----------------- PR Comment with Security Findings ----------------- + # ----------------- Results & Security Gate ----------------- - name: Post Security Scan Results to PR id: post-results if: always() && github.event_name == 'pull_request' - uses: actions/github-script@v7 + uses: LerianStudio/github-actions-shared-workflows/src/security/pr-security-reporter@develop with: - script: | - const fs = require('fs'); - const appName = process.env.APP_NAME; - const dockerScanEnabled = ${{ inputs.enable_docker_scan }}; - - let body = `## 🔒 Security Scan Results — \`${appName}\`\n\n`; - let hasFindings = false; - let hasScanErrors = false; - - // Helper to escape markdown table cell content - const md = (value) => - String(value ?? '') - .replace(/\|/g, '\\|') - .replace(/\r?\n/g, ' ') - .replace(/`/g, '\\`'); - - // Parse filesystem vulnerability scan - try { - const fsVulnFile = `trivy-fs-vuln-${appName}.json`; - if (fs.existsSync(fsVulnFile)) { - const data = JSON.parse(fs.readFileSync(fsVulnFile, 'utf8')); - const results = data.Results || []; - const vulns = []; - - for (const result of results) { - for (const v of (result.Vulnerabilities || [])) { - vulns.push({ - library: v.PkgName || 'Unknown', - id: v.VulnerabilityID || 'N/A', - severity: v.Severity || 'UNKNOWN', - installed: v.InstalledVersion || 'N/A', - fixed: v.FixedVersion || 'N/A', - title: v.Title || v.Description || 'No description' - }); - } - for (const s of (result.Secrets || [])) { - vulns.push({ - library: s.RuleID || 'Secret', - id: s.Category || 'SECRET', - severity: s.Severity || 'HIGH', - installed: '[REDACTED]', - fixed: 'Remove/rotate', - title: s.Title || 'Secret detected' - }); - } - } - - if (vulns.length > 0) { - hasFindings = true; - body += `### Filesystem Scan\n\n`; - body += `| Severity | Library | Vulnerability | Installed | Fixed | Title |\n`; - body += `|----------|---------|---------------|-----------|-------|-------|\n`; - - const severityOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, UNKNOWN: 4 }; - vulns.sort((a, b) => (severityOrder[a.severity] ?? 5) - (severityOrder[b.severity] ?? 5)); - - const maxFsFindings = 50; - for (const v of vulns.slice(0, maxFsFindings)) { - const icon = { CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', LOW: '🔵', UNKNOWN: '⚪' }[v.severity] || '⚪'; - const title = v.title.length > 60 ? v.title.substring(0, 57) + '...' : v.title; - body += `| ${icon} ${md(v.severity)} | \`${md(v.library)}\` | ${md(v.id)} | ${md(v.installed)} | ${md(v.fixed)} | ${md(title)} |\n`; - } - if (vulns.length > maxFsFindings) { - body += `\n_... and ${vulns.length - maxFsFindings} more findings._\n`; - } - body += `\n`; - } else { - body += `### Filesystem Scan\n\n✅ No vulnerabilities or secrets found.\n\n`; - } - } else { - hasScanErrors = true; - body += `### Filesystem Scan\n\n⚠️ Scan artifact not found.\n\n`; - } - } catch (e) { - hasScanErrors = true; - body += `### Filesystem Scan\n\n⚠️ Could not parse scan results: ${e.message}\n\n`; - } - - // Parse Docker image vulnerability scan - if (dockerScanEnabled) { - try { - const dockerSarifFile = `trivy-vulnerability-scan-docker-${appName}.sarif`; - if (fs.existsSync(dockerSarifFile)) { - const sarif = JSON.parse(fs.readFileSync(dockerSarifFile, 'utf8')); - const runs = sarif.runs || []; - const dockerVulns = []; - - for (const run of runs) { - for (const result of (run.results || [])) { - const rule = (run.tool?.driver?.rules || []).find(r => r.id === result.ruleId); - dockerVulns.push({ - id: result.ruleId || 'N/A', - severity: result.level === 'error' ? 'CRITICAL/HIGH' : result.level === 'warning' ? 'MEDIUM' : 'LOW', - title: rule?.shortDescription?.text || result.message?.text || 'No description' - }); - } - } - - if (dockerVulns.length > 0) { - hasFindings = true; - const dockerSeverityOrder = { 'CRITICAL/HIGH': 0, CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, UNKNOWN: 4 }; - dockerVulns.sort((a, b) => (dockerSeverityOrder[a.severity] ?? 5) - (dockerSeverityOrder[b.severity] ?? 5)); - body += `### Docker Image Scan\n\n`; - body += `| Severity | Vulnerability | Title |\n`; - body += `|----------|---------------|-------|\n`; - for (const v of dockerVulns.slice(0, 20)) { - const title = v.title.length > 80 ? v.title.substring(0, 77) + '...' : v.title; - body += `| ${md(v.severity)} | ${md(v.id)} | ${md(title)} |\n`; - } - if (dockerVulns.length > 20) { - body += `\n_... and ${dockerVulns.length - 20} more findings._\n`; - } - body += `\n`; - } else { - body += `### Docker Image Scan\n\n✅ No vulnerabilities found.\n\n`; - } - } else { - hasScanErrors = true; - body += `### Docker Image Scan\n\n⚠️ Scan artifact not found.\n\n`; - } - } catch (e) { - hasScanErrors = true; - body += `### Docker Image Scan\n\n⚠️ Could not parse scan results: ${e.message}\n\n`; - } - } - - if (!hasFindings && !hasScanErrors) { - body += `\n✅ **All security checks passed.**\n`; - } - - // Export findings flag for the gate step (before API calls to ensure they're always set) - core.setOutput('has_findings', hasFindings); - core.setOutput('has_errors', hasScanErrors); - - // Find and update existing comment or create new one - const marker = ``; - body = marker + '\n' + body; - try { - 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)); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body - }); - } - } catch (e) { - core.warning(`Could not post PR security comment: ${e.message}`); - } - - - name: Gate - Fail on Security Findings - if: always() && github.event_name == 'pull_request' - run: | - if [ "${{ steps.post-results.outputs.has_errors }}" = "true" ]; then - echo "::warning::Some scan artifacts were missing or could not be parsed." - fi - if [ "${{ steps.post-results.outputs.has_findings }}" = "true" ]; then - echo "::error::Security vulnerabilities found. Check the PR comment for details." - exit 1 - fi + github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} + app-name: ${{ env.APP_NAME }} + enable-docker-scan: ${{ inputs.enable_docker_scan }} + enable-health-score: ${{ inputs.enable_health_score && inputs.enable_docker_scan }} + dockerfile-has-non-root-user: ${{ steps.dockerfile-checks.outputs.has-non-root-user || 'false' }} + fail-on-findings: 'true' ## To be fixed # - name: Upload Secret Scan Results - Repository (SARIF) to GitHub Security Tab @@ -413,15 +207,17 @@ jobs: # with: # sarif_file: 'trivy-vulnerability-scan-docker-${{ env.APP_NAME }}.sarif' - # Slack notification + # ----------------- Slack Notification ----------------- notify: name: Notify needs: [prepare_matrix, security_scan] if: always() && needs.prepare_matrix.outputs.matrix != '[]' - uses: ./.github/workflows/slack-notify.yml - with: - status: ${{ needs.security_scan.result }} - workflow_name: "PR Security Scan" - failed_jobs: ${{ needs.security_scan.result == 'failure' && 'Security Scan' || '' }} - secrets: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + runs-on: ${{ inputs.runner_type }} + steps: + - name: Slack Notification + uses: LerianStudio/github-actions-shared-workflows/src/notify/slack-notify@develop + with: + webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + status: ${{ needs.security_scan.result }} + workflow-name: "PR Security Scan" + failed-jobs: ${{ needs.security_scan.result == 'failure' && 'Security Scan' || '' }} diff --git a/.github/workflows/release-notification.yml b/.github/workflows/release-notification.yml new file mode 100644 index 00000000..dcda48d3 --- /dev/null +++ b/.github/workflows/release-notification.yml @@ -0,0 +1,185 @@ +name: "Release Notification" + +on: + workflow_call: + inputs: + product_name: + description: Product name displayed in notifications + required: true + type: string + slack_channel: + description: Slack channel name + required: false + type: string + default: "" + discord_color: + description: Discord embed color (decimal) + required: false + type: string + default: "2105893" + discord_username: + description: Bot username displayed in Discord + required: false + type: string + default: "Release Changelog" + discord_content: + description: Discord message content (e.g. role mentions) + required: false + type: string + default: "" + skip_beta_discord: + description: Skip Discord notification for beta releases + required: false + type: boolean + default: true + slack_color: + description: Sidebar color for the Slack message + required: false + type: string + default: "#36a64f" + slack_icon_emoji: + description: Emoji icon for the Slack bot + required: false + type: string + default: ":rocket:" + dry_run: + description: Preview changes without sending notifications + required: false + type: boolean + default: false + secrets: + APP_ID: + description: GitHub App ID for authentication + required: true + APP_PRIVATE_KEY: + description: GitHub App private key + required: true + DISCORD_WEBHOOK_URL: + description: Discord webhook URL + required: false + SLACK_WEBHOOK_URL: + description: Slack webhook URL + required: false + workflow_dispatch: + inputs: + product_name: + description: Product name displayed in notifications + required: true + type: string + slack_channel: + description: Slack channel name + required: false + type: string + default: "" + discord_color: + description: Discord embed color (decimal) + required: false + type: string + default: "2105893" + discord_username: + description: Bot username displayed in Discord + required: false + type: string + default: "Release Changelog" + discord_content: + description: Discord message content (e.g. role mentions) + required: false + type: string + default: "" + skip_beta_discord: + description: Skip Discord notification for beta releases + type: boolean + default: true + slack_color: + description: Sidebar color for the Slack message + required: false + type: string + default: "#36a64f" + slack_icon_emoji: + description: Emoji icon for the Slack bot + required: false + type: string + default: ":rocket:" + dry_run: + description: Preview changes without sending notifications + type: boolean + default: false + +jobs: + notify: + name: Release Notification + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + steps: + - name: Create GitHub App token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v6 + + - name: Fetch latest release tag + id: release + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + TAG='${{ github.event.release.tag_name }}' + if [[ -z "$TAG" ]]; then + echo "No release event tag — falling back to gh release list" + TAG=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 1 --json tagName --jq '.[0].tagName') + fi + if [[ -z "$TAG" || "$TAG" == "null" ]]; then + echo "::error::No release tag resolved — aborting" + exit 1 + fi + echo "Resolved release tag: $TAG" + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Dry run summary + if: ${{ inputs.dry_run }} + run: | + ENABLE_DISCORD="${{ env.DISCORD_WEBHOOK_URL != '' && 'true' || 'false' }}" + ENABLE_SLACK="${{ env.SLACK_WEBHOOK_URL != '' && inputs.slack_channel != '' && 'true' || 'false' }}" + echo "::notice::DRY RUN — no notifications will be sent" + echo " product_name : ${{ inputs.product_name }}" + echo " release_tag : ${{ steps.release.outputs.tag }}" + echo " discord_webhook : ${{ env.DISCORD_WEBHOOK_URL != '' && 'configured' || 'not set' }}" + echo " discord_color : ${{ inputs.discord_color }}" + echo " discord_username : ${{ inputs.discord_username }}" + echo " discord_content : ${{ inputs.discord_content }}" + echo " skip_beta_discord: ${{ inputs.skip_beta_discord }}" + echo " enable_discord : ${ENABLE_DISCORD}" + echo " slack_webhook : ${{ env.SLACK_WEBHOOK_URL != '' && 'configured' || 'not set' }}" + echo " slack_channel : ${{ inputs.slack_channel }}" + echo " slack_color : ${{ inputs.slack_color }}" + echo " slack_icon_emoji : ${{ inputs.slack_icon_emoji }}" + echo " enable_slack : ${ENABLE_SLACK}" + + - name: Discord notification + if: ${{ env.DISCORD_WEBHOOK_URL != '' }} + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@develop + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + release-tag: ${{ steps.release.outputs.tag }} + color: ${{ inputs.discord_color }} + username: ${{ inputs.discord_username }} + content: ${{ inputs.discord_content }} + skip-beta: ${{ inputs.skip_beta_discord }} + dry-run: ${{ inputs.dry_run }} + + - name: Slack notification + if: ${{ env.SLACK_WEBHOOK_URL != '' && inputs.slack_channel != '' }} + uses: LerianStudio/github-actions-shared-workflows/src/notify/slack-release@develop + with: + webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + channel: ${{ inputs.slack_channel }} + product-name: ${{ inputs.product_name }} + release-tag: ${{ steps.release.outputs.tag }} + color: ${{ inputs.slack_color }} + icon-emoji: ${{ inputs.slack_icon_emoji }} + dry-run: ${{ inputs.dry_run }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65009054..7179f3de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,11 @@ on: type: string required: false default: '' + shared_paths: + description: 'Newline-separated path patterns (e.g., "go.mod\ngo.sum") that trigger a build for ALL components when matched by any changed file.' + type: string + required: false + default: '' path_level: description: 'Limits the path to the first N segments (e.g., 2 -> "apps/agent")' type: string @@ -42,24 +47,25 @@ jobs: COMMIT_MSG: ${{ github.event.head_commit.message }} run: | echo "📌 Checking commit message for skip patterns" - + # Skip if commit message contains [skip ci] or is a changelog update if echo "$COMMIT_MSG" | grep -qiE '\[skip ci\]|chore\(release\): Update CHANGELOGs'; then echo "🛑 Skipping release - changelog/skip-ci commit detected" - echo "should_skip=true" >> $GITHUB_OUTPUT + echo "should_skip=true" >> "$GITHUB_OUTPUT" else echo "✅ Proceeding with release" - echo "should_skip=false" >> $GITHUB_OUTPUT + echo "should_skip=false" >> "$GITHUB_OUTPUT" fi - name: Get changed paths (monorepo) if: inputs.filter_paths != '' id: changed-paths - uses: LerianStudio/github-actions-changed-paths@main + uses: LerianStudio/github-actions-shared-workflows/src/config/changed-paths@develop with: - filter_paths: ${{ inputs.filter_paths }} - path_level: ${{ inputs.path_level }} - get_app_name: 'true' + filter-paths: ${{ inputs.filter_paths }} + shared-paths: ${{ inputs.shared_paths }} + path-level: ${{ inputs.path_level }} + get-app-name: 'true' - name: Set matrix id: set-matrix @@ -67,16 +73,16 @@ jobs: if [ -z "${{ inputs.filter_paths }}" ]; then # Single app mode - release from root APP_NAME="${{ github.event.repository.name }}" - echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> $GITHUB_OUTPUT - echo "has_changes=true" >> $GITHUB_OUTPUT + echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" else MATRIX='${{ steps.changed-paths.outputs.matrix }}' if [ "$MATRIX" == "[]" ] || [ -z "$MATRIX" ]; then - echo "matrix=[]" >> $GITHUB_OUTPUT - echo "has_changes=false" >> $GITHUB_OUTPUT + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_changes=false" >> "$GITHUB_OUTPUT" else - echo "matrix=$MATRIX" >> $GITHUB_OUTPUT - echo "has_changes=true" >> $GITHUB_OUTPUT + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" fi fi @@ -115,7 +121,7 @@ jobs: git reset --hard origin/${{ github.ref_name }} - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@v7 id: import_gpg with: gpg_private_key: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY }} diff --git a/.github/workflows/self-pr-validation.yml b/.github/workflows/self-pr-validation.yml new file mode 100644 index 00000000..c98093f3 --- /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/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml new file mode 100644 index 00000000..297b98e3 --- /dev/null +++ b/.github/workflows/typescript-build.yml @@ -0,0 +1,337 @@ +name: "TypeScript Build and Push Docker Images" + +# Reusable workflow for building and pushing Docker images from TypeScript/Node.js projects +# Built-in npmrc secret for GitHub Packages private @lerianstudio dependencies +# +# Features: +# - Native npmrc authentication for GitHub Packages (always injected) +# - Multi-component matrix via components_json or monorepo change detection +# - Multi-registry support (GHCR and/or DockerHub) +# - Platform strategy: beta/rc builds amd64 only, release builds amd64+arm64 +# - Semantic versioning tags +# - GitOps artifacts upload for downstream gitops-update workflow +# - Helm chart dispatch for automatic version updates in Helm repositories +# - Dry-run mode for validation without pushing images +# +# Why use this instead of build.yml? +# - build.yml defaults to DockerHub and requires manual build_secrets for npmrc +# - typescript-build.yml defaults to GHCR, always injects npmrc, and treats +# build_secrets as additive (extra secrets on top of npmrc) + +on: + workflow_call: + inputs: + runner_type: + description: 'Runner to use for the workflow' + type: string + default: 'blacksmith-4vcpu-ubuntu-2404' + dry_run: + description: 'Preview changes without pushing images' + required: false + type: boolean + default: false + filter_paths: + description: 'Newline-separated list of path prefixes to filter. If not provided, builds from root.' + type: string + required: false + default: '' + shared_paths: + description: 'Newline-separated path patterns (e.g., "go.mod\ngo.sum") that trigger a build for ALL components when matched by any changed file.' + type: string + required: false + default: '' + path_level: + description: 'Limits the path to the first N segments (e.g., 2 -> "apps/agent")' + type: string + default: '2' + enable_dockerhub: + description: 'Enable pushing to DockerHub' + type: boolean + default: false + enable_ghcr: + description: 'Enable pushing to GitHub Container Registry' + type: boolean + default: true + dockerhub_org: + description: 'DockerHub organization name' + type: string + default: 'lerianstudio' + ghcr_org: + description: 'GHCR organization name (defaults to repository owner)' + type: string + default: '' + dockerfile_name: + description: 'Name of the Dockerfile' + type: string + default: 'Dockerfile' + app_name_prefix: + description: 'Prefix for app names in monorepo (e.g., "midaz" results in "midaz-agent")' + type: string + default: '' + app_name_overrides: + description: 'Explicit app name mappings in "path:name" format. Use "path:" for prefix-only.' + type: string + default: '' + build_context: + description: 'Docker build context (defaults to repository root)' + type: string + default: '.' + build_secrets: + description: 'Additional secrets for docker build (one per line). npmrc is always included automatically.' + type: string + default: '' + enable_gitops_artifacts: + description: 'Enable GitOps artifacts upload for downstream gitops-update workflow' + type: boolean + default: false + components_json: + description: 'JSON array of components to build. Each entry must have "name" and "working_dir", optionally "dockerfile". Skips change detection when provided.' + type: string + default: '' + normalize_to_filter: + description: 'Normalize changed paths to their filter path. Recommended for monorepos.' + type: boolean + default: true + # Helm dispatch configuration + enable_helm_dispatch: + description: 'Enable dispatching to Helm repository for chart updates' + type: boolean + default: false + helm_repository: + description: 'Helm repository to dispatch to (org/repo format)' + type: string + default: 'LerianStudio/helm' + helm_chart: + description: 'Helm chart name to update' + type: string + default: '' + helm_target_ref: + description: 'Target branch in Helm repository' + type: string + default: 'main' + helm_components_base_path: + description: 'Base path for components in source repo' + type: string + default: 'components' + helm_env_file: + description: 'Env example file name relative to component path' + type: string + default: '.env.example' + helm_detect_env_changes: + description: 'Whether to detect new environment variables for Helm' + type: boolean + default: true + helm_dispatch_on_rc: + description: 'Enable Helm dispatch for release-candidate (rc) tags' + type: boolean + default: false + helm_dispatch_on_beta: + description: 'Enable Helm dispatch for beta tags' + type: boolean + default: false + helm_values_key_mappings: + description: 'JSON mapping of component names to values.yaml keys' + type: string + default: '' + + workflow_dispatch: + inputs: + dry_run: + description: 'Preview changes without pushing images' + type: boolean + default: true + +permissions: + contents: read + packages: write + +jobs: + prepare: + runs-on: ${{ inputs.runner_type }} + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + has_builds: ${{ steps.set-matrix.outputs.has_builds }} + platforms: ${{ steps.set-platforms.outputs.platforms }} + is_release: ${{ steps.set-platforms.outputs.is_release }} + version: ${{ steps.set-version.outputs.version }} + steps: + - name: Get changed paths (monorepo) + if: inputs.components_json == '' && inputs.filter_paths != '' + id: changed-paths + uses: LerianStudio/github-actions-shared-workflows/src/config/changed-paths@develop + with: + filter-paths: ${{ inputs.filter_paths }} + shared-paths: ${{ inputs.shared_paths }} + path-level: ${{ inputs.path_level }} + get-app-name: 'true' + app-name-prefix: ${{ inputs.app_name_prefix }} + app-name-overrides: ${{ inputs.app_name_overrides }} + normalize-to-filter: ${{ inputs.normalize_to_filter }} + + - name: Set matrix + id: set-matrix + env: + COMPONENTS_JSON: ${{ inputs.components_json }} + FILTER_PATHS: ${{ inputs.filter_paths }} + CHANGED_MATRIX: ${{ steps.changed-paths.outputs.matrix }} + REPO_NAME: ${{ github.event.repository.name }} + run: | + if [ -n "$COMPONENTS_JSON" ]; then + { + echo 'matrix<> "$GITHUB_OUTPUT" + echo "has_builds=true" >> "$GITHUB_OUTPUT" + elif [ -z "$FILTER_PATHS" ]; then + echo "matrix=[{\"name\": \"${REPO_NAME}\", \"working_dir\": \".\"}]" >> "$GITHUB_OUTPUT" + echo "has_builds=true" >> "$GITHUB_OUTPUT" + else + if [ "$CHANGED_MATRIX" == "[]" ] || [ -z "$CHANGED_MATRIX" ]; then + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_builds=false" >> "$GITHUB_OUTPUT" + else + { + echo 'matrix<> "$GITHUB_OUTPUT" + echo "has_builds=true" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Set platforms based on tag type + id: set-platforms + env: + REF_TYPE: ${{ github.ref_type }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + if [ "$REF_TYPE" != "tag" ] && [ "$DRY_RUN" != "true" ]; then + echo "::error::This workflow requires a tag ref. Use dry_run for branch dispatches." + exit 1 + fi + TAG="${GITHUB_REF#refs/tags/}" + if [[ "$TAG" == *"-beta"* ]] || [[ "$TAG" == *"-rc"* ]]; then + echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT" + echo "is_release=false" >> "$GITHUB_OUTPUT" + else + echo "platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT" + echo "is_release=true" >> "$GITHUB_OUTPUT" + fi + + - name: Extract version from tag + id: set-version + env: + REF_TYPE: ${{ github.ref_type }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + if [ "$REF_TYPE" != "tag" ]; then + if [ "$DRY_RUN" == "true" ]; then + echo "version=v0.0.0-dry-run" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "::error::Cannot extract version — ref is not a tag." + exit 1 + fi + # Strips optional prefix from tags like "my-app-v1.0.0" → "v1.0.0" + # For standard semver tags (v1.0.0, v1.0.0-beta.1) this is a no-op + # shellcheck disable=SC2001 + VERSION=$(echo "$TAG" | sed 's/.*-\(v[0-9]\)/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + build: + needs: prepare + if: needs.prepare.outputs.has_builds == 'true' + runs-on: ${{ inputs.runner_type }} + name: Build ${{ matrix.app.name }} + strategy: + max-parallel: 2 + fail-fast: false + matrix: + app: ${{ fromJson(needs.prepare.outputs.matrix) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build and push Docker image + uses: LerianStudio/github-actions-shared-workflows/src/build/docker-build-ts@develop + with: + enable-dockerhub: ${{ inputs.enable_dockerhub }} + enable-ghcr: ${{ inputs.enable_ghcr }} + dockerhub-org: ${{ inputs.dockerhub_org }} + ghcr-org: ${{ inputs.ghcr_org }} + dockerhub-username: ${{ secrets.DOCKER_USERNAME }} + dockerhub-password: ${{ secrets.DOCKER_PASSWORD }} + ghcr-token: ${{ secrets.MANAGE_TOKEN }} + app-name: ${{ matrix.app.name }} + working-dir: ${{ matrix.app.working_dir }} + dockerfile: ${{ matrix.app.dockerfile }} + dockerfile-name: ${{ inputs.dockerfile_name }} + build-context: ${{ matrix.app.context || inputs.build_context }} + build-secrets: ${{ inputs.build_secrets }} + platforms: ${{ needs.prepare.outputs.platforms }} + version: ${{ needs.prepare.outputs.version }} + is-release: ${{ needs.prepare.outputs.is_release }} + dry-run: ${{ inputs.dry_run }} + + # GitOps artifacts for downstream gitops-update workflow + - name: Create GitOps tag artifact + if: inputs.enable_gitops_artifacts && !inputs.dry_run + run: | + set -euo pipefail + mkdir -p gitops-tags + VERSION="${{ needs.prepare.outputs.version }}" + VERSION="${VERSION#v}" + printf "%s" "$VERSION" > "gitops-tags/${{ matrix.app.name }}.tag" + echo "Created artifact: gitops-tags/${{ matrix.app.name }}.tag with version: $VERSION" + + - name: Upload GitOps tag artifact + if: inputs.enable_gitops_artifacts && !inputs.dry_run + uses: actions/upload-artifact@v7 + with: + name: gitops-tags-${{ matrix.app.name }} + path: gitops-tags/ + + # Slack notification + notify: + name: Notify + needs: [prepare, build] + if: always() && needs.prepare.outputs.has_builds == 'true' && !inputs.dry_run + uses: ./.github/workflows/slack-notify.yml + with: + status: ${{ needs.build.result }} + workflow_name: "TypeScript Build & Push Docker Images" + failed_jobs: ${{ needs.build.result == 'failure' && 'Build' || '' }} + secrets: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + # Dispatch to Helm repository for chart updates + dispatch-helm: + name: Dispatch Helm Update + needs: [prepare, build] + if: | + inputs.enable_helm_dispatch && + !inputs.dry_run && + needs.prepare.outputs.has_builds == 'true' && + needs.build.result == 'success' && + inputs.helm_chart != '' && + ( + needs.prepare.outputs.is_release == 'true' || + (contains(github.ref, '-rc') && inputs.helm_dispatch_on_rc) || + (contains(github.ref, '-beta') && inputs.helm_dispatch_on_beta) + ) + uses: ./.github/workflows/dispatch-helm.yml + with: + helm_repository: ${{ inputs.helm_repository }} + chart: ${{ inputs.helm_chart }} + target_ref: ${{ inputs.helm_target_ref }} + version: ${{ needs.prepare.outputs.version }} + components_json: ${{ needs.prepare.outputs.matrix }} + components_base_path: ${{ inputs.helm_components_base_path }} + env_file: ${{ inputs.helm_env_file }} + detect_env_changes: ${{ inputs.helm_detect_env_changes }} + values_key_mappings: ${{ inputs.helm_values_key_mappings }} + runner_type: ${{ inputs.runner_type }} + secrets: + helm_repo_token: ${{ secrets.HELM_REPO_TOKEN }} diff --git a/.github/workflows/typescript-ci.yml b/.github/workflows/typescript-ci.yml index 78baa841..778e9485 100644 --- a/.github/workflows/typescript-ci.yml +++ b/.github/workflows/typescript-ci.yml @@ -83,10 +83,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node_version_primary }} cache: ${{ inputs.package_manager }} @@ -122,10 +122,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js ${{ matrix.node }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} cache: ${{ inputs.package_manager }} @@ -152,7 +152,7 @@ jobs: - name: Upload coverage artifact if: inputs.enable_coverage && matrix.node == inputs.node_version_primary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report path: coverage/ @@ -176,10 +176,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node_version_primary }} cache: ${{ inputs.package_manager }} @@ -206,12 +206,12 @@ jobs: continue-on-error: true - name: Run CodeQL Analysis - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: javascript-typescript - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 docs: name: Generate Documentation @@ -224,10 +224,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node_version_primary }} cache: ${{ inputs.package_manager }} @@ -246,7 +246,7 @@ jobs: run: ${{ inputs.docs_cmd }} - name: Upload documentation artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: documentation path: docs/ diff --git a/.github/workflows/typescript-release.yml b/.github/workflows/typescript-release.yml index 5eedec31..025c9901 100644 --- a/.github/workflows/typescript-release.yml +++ b/.github/workflows/typescript-release.yml @@ -28,6 +28,11 @@ on: type: string required: false default: '' + shared_paths: + description: 'Newline-separated path patterns (e.g., "go.mod\ngo.sum") that trigger a build for ALL components when matched by any changed file.' + type: string + required: false + default: '' path_level: description: 'Limits the path to the first N segments (e.g., 2 -> "apps/agent")' type: string @@ -55,20 +60,21 @@ jobs: # Skip if commit message contains [skip ci] or is a changelog update if echo "$COMMIT_MSG" | grep -qiE '\[skip ci\]|chore\(release\): Update CHANGELOGs'; then echo "Skipping release - changelog/skip-ci commit detected" - echo "should_skip=true" >> $GITHUB_OUTPUT + echo "should_skip=true" >> "$GITHUB_OUTPUT" else echo "Proceeding with release" - echo "should_skip=false" >> $GITHUB_OUTPUT + echo "should_skip=false" >> "$GITHUB_OUTPUT" fi - name: Get changed paths (monorepo) if: inputs.filter_paths != '' id: changed-paths - uses: LerianStudio/github-actions-changed-paths@main + uses: LerianStudio/github-actions-shared-workflows/src/config/changed-paths@develop with: - filter_paths: ${{ inputs.filter_paths }} - path_level: ${{ inputs.path_level }} - get_app_name: 'true' + filter-paths: ${{ inputs.filter_paths }} + shared-paths: ${{ inputs.shared_paths }} + path-level: ${{ inputs.path_level }} + get-app-name: 'true' - name: Set matrix id: set-matrix @@ -76,16 +82,16 @@ jobs: if [ -z "${{ inputs.filter_paths }}" ]; then # Single app mode - release from root APP_NAME="${{ github.event.repository.name }}" - echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> $GITHUB_OUTPUT - echo "has_changes=true" >> $GITHUB_OUTPUT + echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" else MATRIX='${{ steps.changed-paths.outputs.matrix }}' if [ "$MATRIX" == "[]" ] || [ -z "$MATRIX" ]; then - echo "matrix=[]" >> $GITHUB_OUTPUT - echo "has_changes=false" >> $GITHUB_OUTPUT + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_changes=false" >> "$GITHUB_OUTPUT" else - echo "matrix=$MATRIX" >> $GITHUB_OUTPUT - echo "has_changes=true" >> $GITHUB_OUTPUT + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" fi fi @@ -126,7 +132,7 @@ jobs: git reset --hard origin/${{ github.ref_name }} - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@v7 id: import_gpg with: gpg_private_key: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY }} diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 00000000..681d7d6b --- /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/AGENTS.md b/AGENTS.md index 3f9ab3ca..f28d7149 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,18 +36,57 @@ Full rules: - Reusable workflows → `.cursor/rules/reusable-workflows.mdc` or `/workflow` - Modifying existing files → `.cursor/rules/refactoring.mdc` or `/refactor` -### Local path rule for composites +### Composite action references in reusable workflows -Inside a reusable workflow, always reference composite actions with a **local path**: +In reusable workflows (`workflow_call`), `uses: ./path` resolves to the **caller's workspace**, not this repository. This means `./src/...` only works when the caller IS this repo (i.e., `self-*` workflows). + +- **Workflows called by external repos** — use an external ref pinned to a release tag: + + ```yaml + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@v1.2.3 # ✅ pinned + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@develop # ⚠️ testing only + uses: ./src/notify/discord-release # ❌ resolves to caller's workspace + ``` + +- **`self-*` workflows (internal only)** — use a local path: + + ```yaml + uses: ./.github/workflows/labels-sync.yml # ✅ caller is this repo + ``` + +### Skip-enabling outputs + +Every reusable workflow and composite action that performs conditional work (e.g. change detection, feature-flag checks) **must expose boolean outputs** so callers can skip downstream jobs when there is nothing to do. + +```yaml +# Reusable workflow example +outputs: + has_builds: + description: 'Whether any components were detected for building (true/false)' + value: ${{ jobs.prepare.outputs.has_builds }} + +# Composite action example +outputs: + has_changes: + description: 'Whether any changes were detected (true/false)' + value: ${{ steps.detect.outputs.has_changes }} +``` + +Callers use these outputs to gate dependent jobs: ```yaml -uses: ./src/config/labels-sync # ✅ version-safe -uses: LerianStudio/...@main # ❌ breaks versioning for callers on older tags +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/build.yml@v1.2.3 + deploy: + needs: build + if: needs.build.outputs.has_builds == 'true' + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/deploy.yml@v1.2.3 ``` ### dry_run -Every reusable workflow must include a `dry_run` input (`boolean`, `default: false`). +Every reusable workflow must include a `dry_run` input (`boolean`, `default: false`). `dry_run: true` must be verbose (print all resolved values, use tool debug flags). `dry_run: false` must be silent (no extra echo, no debug flags). ### Branches and commits diff --git a/docs/build-workflow.md b/docs/build-workflow.md index 5d83decb..b665d13a 100644 --- a/docs/build-workflow.md +++ b/docs/build-workflow.md @@ -6,7 +6,7 @@ Reusable workflow for building and pushing Docker images to container registries - **Monorepo support**: Automatic detection of changed components via filter_paths - **Multi-registry**: Push to DockerHub and/or GitHub Container Registry (GHCR) -- **Smart platform builds**: Beta/RC tags build amd64 only, release tags build amd64+arm64 +- **Smart platform builds**: Beta/RC tags build amd64 only (unless `force_multiplatform` is enabled), release tags build amd64+arm64 - **Semantic versioning**: Automatic tag extraction and Docker metadata - **GitOps integration**: Upload artifacts for downstream gitops-update workflow - **Slack notifications**: Automatic success/failure notifications @@ -106,6 +106,7 @@ jobs: | `app_name_prefix` | string | `''` | Prefix for app names in monorepo | | `build_context` | string | `.` | Docker build context | | `enable_gitops_artifacts` | boolean | `false` | Upload artifacts for gitops-update workflow | +| `force_multiplatform` | boolean | `false` | Force multi-platform build (amd64+arm64) even for beta/rc tags | ## Secrets @@ -122,11 +123,13 @@ Uses `secrets: inherit` pattern. Required secrets: The workflow automatically selects platforms based on the tag type: -| Tag Type | Example | Platforms | Rationale | -|----------|---------|-----------|-----------| -| Beta | `v1.0.0-beta.1` | `linux/amd64` | Faster CI for development | -| RC | `v1.0.0-rc.1` | `linux/amd64` | Faster CI for staging | -| Release | `v1.0.0` | `linux/amd64,linux/arm64` | Full multi-arch support | +| Tag Type | `force_multiplatform` | Platforms | Rationale | +|----------|----------------------|-----------|-----------| +| Beta | `false` (default) | `linux/amd64` | Faster CI for development | +| Beta | `true` | `linux/amd64,linux/arm64` | Multi-arch needed in dev | +| RC | `false` (default) | `linux/amd64` | Faster CI for staging | +| RC | `true` | `linux/amd64,linux/arm64` | Multi-arch needed in staging | +| Release | N/A | `linux/amd64,linux/arm64` | Always full multi-arch support | ## Docker Image Tags @@ -220,7 +223,7 @@ Automatically sends notifications on completion: **Issue**: ARM64 builds take too long -**Solution**: ARM64 builds only run on release tags. Beta/RC tags build amd64 only for faster CI. +**Solution**: ARM64 builds only run on release tags by default. Beta/RC tags build amd64 only for faster CI. If you need ARM64 on beta/rc, use `force_multiplatform: true` and be aware of the longer build times. ## Related Workflows diff --git a/docs/changed-paths-workflow.md b/docs/changed-paths-workflow.md deleted file mode 100644 index 405ca4b9..00000000 --- a/docs/changed-paths-workflow.md +++ /dev/null @@ -1,255 +0,0 @@ -# Changed Paths Workflow - -Reusable workflow for detecting changed paths between commits. Useful for monorepo setups to trigger builds only for components that have changed, enabling efficient CI/CD pipelines with matrix strategies. - -## Features - -- Detect changed files between commits -- Filter paths by prefix patterns -- Limit path depth to specific segments -- Generate app name matrix for monorepo deployments -- Customizable app name prefix -- Output suitable for GitHub Actions matrix strategy -- Handles edge cases (first commit, tags, missing refs) - -## Usage - -### Basic Usage - -```yaml -name: CI -on: - push: - branches: [main] - -jobs: - detect-changes: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 - - build: - needs: detect-changes - if: needs.detect-changes.outputs.has_changes == 'true' - runs-on: ubuntu-latest - strategy: - matrix: - path: ${{ fromJson(needs.detect-changes.outputs.matrix) }} - steps: - - name: Build changed component - run: echo "Building ${{ matrix.path }}" -``` - -### Monorepo with Path Filtering - -```yaml -name: Monorepo CI -on: - push: - branches: [main] - -jobs: - detect-changes: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 - with: - filter_paths: '["components/api", "components/web", "components/worker"]' - path_level: 2 - - build: - needs: detect-changes - if: needs.detect-changes.outputs.has_changes == 'true' - runs-on: ubuntu-latest - strategy: - matrix: - path: ${{ fromJson(needs.detect-changes.outputs.matrix) }} - steps: - - uses: actions/checkout@v4 - - name: Build - working-directory: ${{ matrix.path }} - run: make build -``` - -### With App Name Generation - -```yaml -name: Deploy Changed Apps -on: - push: - branches: [main] - -jobs: - detect-changes: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 - with: - filter_paths: '["components/onboarding", "components/transaction", "components/ledger"]' - path_level: 2 - get_app_name: true - app_name_prefix: 'midaz' - - deploy: - needs: detect-changes - if: needs.detect-changes.outputs.has_changes == 'true' - runs-on: ubuntu-latest - strategy: - matrix: - app: ${{ fromJson(needs.detect-changes.outputs.matrix) }} - steps: - - uses: actions/checkout@v4 - - name: Deploy - run: | - echo "Deploying app: ${{ matrix.app.name }}" - echo "Working directory: ${{ matrix.app.working_dir }}" -``` - -## Inputs - -| Input | Description | Required | Default | -|-------|-------------|----------|---------| -| `filter_paths` | JSON array of path prefixes to filter results | No | `''` | -| `path_level` | Limits the path to the first N segments | No | `0` (disabled) | -| `get_app_name` | Output matrix with `name` and `working_dir` fields | No | `false` | -| `app_name_prefix` | Prefix to add to each app name | No | `''` | -| `runner_type` | GitHub runner type | No | `ubuntu-latest` | - -## Outputs - -| Output | Description | -|--------|-------------| -| `matrix` | JSON array of changed directories (or objects if `get_app_name` is true) | -| `has_changes` | Boolean string (`'true'` or `'false'`) indicating if changes were detected | - -## Output Formats - -### Default Format (get_app_name: false) - -```json -["components/api", "components/web", "libs/common"] -``` - -### App Name Format (get_app_name: true) - -```json -[ - {"name": "api", "working_dir": "components/api"}, - {"name": "web", "working_dir": "components/web"} -] -``` - -### With Prefix (get_app_name: true, app_name_prefix: "myapp") - -```json -[ - {"name": "myapp-api", "working_dir": "components/api"}, - {"name": "myapp-web", "working_dir": "components/web"} -] -``` - -## Jobs - -### get-changed-paths - -Detects changed files and extracts unique directories with optional filtering. - -**Steps:** -1. Checkout code with full history -2. Compare commits to get changed files -3. Extract and deduplicate directories -4. Apply path level trimming (if configured) -5. Filter by path prefixes (if configured) -6. Generate output matrix - -## Example Configurations - -### Simple Change Detection - -```yaml -jobs: - changes: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 -``` - -### Microservices Monorepo - -```yaml -jobs: - changes: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 - with: - filter_paths: '["services/auth", "services/users", "services/orders", "services/payments"]' - path_level: 2 - get_app_name: true - app_name_prefix: 'platform' -``` - -### Frontend Monorepo - -```yaml -jobs: - changes: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 - with: - filter_paths: '["packages/ui", "packages/utils", "apps/web", "apps/mobile"]' - path_level: 2 -``` - -### Conditional Job Execution - -```yaml -jobs: - detect: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 - with: - filter_paths: '["src/backend"]' - - backend-tests: - needs: detect - if: needs.detect.outputs.has_changes == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: make test-backend -``` - -## How Path Level Works - -The `path_level` input trims paths to the first N segments: - -| Original Path | path_level | Result | -|---------------|------------|--------| -| `components/api/src/main.go` | 1 | `components` | -| `components/api/src/main.go` | 2 | `components/api` | -| `components/api/src/main.go` | 3 | `components/api/src` | -| `services/auth/handlers/login.ts` | 2 | `services/auth` | - -## Tips - -1. **Pin to a version tag**: Use `@v1.0.0` instead of `@v1.0.0` for production stability -2. **Use `has_changes` output**: Skip downstream jobs when no relevant changes are detected -3. **Path level for consistency**: Use `path_level` to normalize paths to component directories -4. **Filter early**: Use `filter_paths` to focus on relevant directories and reduce noise -5. **Matrix strategy**: Combine with GitHub's matrix strategy for parallel builds - -## Requirements - -This workflow uses `jq` for JSON processing, which is preinstalled on all GitHub-hosted runners. - -For self-hosted runners, ensure `jq` is available: - -```bash -# Debian/Ubuntu -sudo apt-get update && sudo apt-get install -y jq - -# macOS -brew install jq - -# Alpine -apk add jq -``` - -## Related Workflows - -- [Go CI](./go-ci-workflow.md) - Continuous integration for Go projects -- [GitOps Update](./gitops-update-workflow.md) - Update GitOps repository with new image tags - ---- - -**Last Updated:** 2025-11-27 -**Version:** 1.0.0 diff --git a/docs/go-fuzz.md b/docs/go-fuzz.md new file mode 100644 index 00000000..f96f8f40 --- /dev/null +++ b/docs/go-fuzz.md @@ -0,0 +1,84 @@ + + + + + +
Lerian

go-fuzz

+ +Reusable workflow for running Go fuzz tests. Executes a configurable fuzz command and uploads failure artifacts for analysis. + +## Inputs + +| Input | Description | Required | Default | +|---|---|---|---| +| `runner_type` | GitHub runner type to use | No | `blacksmith-4vcpu-ubuntu-2404` | +| `go_version` | Go version to use | No | `1.25` | +| `fuzz_command` | Command to run fuzz tests | No | `make fuzz-ci` | +| `fuzz_artifacts_path` | Path pattern for fuzz failure artifacts | No | `tests/fuzz/**/testdata/fuzz/` | +| `artifacts_retention_days` | Number of days to retain fuzz failure artifacts | No | `7` | +| `timeout_minutes` | Maximum job duration in minutes (safety net for unbounded fuzz) | No | `30` | +| `dry_run` | Preview configuration without running fuzz tests | No | `false` | + +## Usage + +### Production + +```yaml +name: Fuzz Tests + +on: + schedule: + - cron: '0 0 * * 0' + workflow_dispatch: + +permissions: + contents: read + +jobs: + fuzz: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/go-fuzz.yml@v1.12.0 + with: + go_version: '1.25' +``` + +### Testing + +```yaml +jobs: + fuzz: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/go-fuzz.yml@develop + with: + go_version: '1.25' + dry_run: true +``` + +### Custom fuzz command + +```yaml +jobs: + fuzz: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/go-fuzz.yml@v1.12.0 + with: + go_version: '1.25' + fuzz_command: 'go test -fuzz=. -fuzztime=30s ./...' + fuzz_artifacts_path: '**/testdata/fuzz/' +``` + +## Fuzz command requirements + +The default `fuzz_command` is `make fuzz-ci`. Caller repositories must either: + +1. Have a `fuzz-ci` target in their `Makefile` (recommended — allows configuring `-fuzztime` per repo) +2. Override `fuzz_command` with a direct Go command, e.g.: + ```yaml + fuzz_command: 'go test -fuzz=. -fuzztime=60s ./...' + ``` + +The `timeout_minutes` input (default: 30) acts as a safety net to prevent unbounded fuzz runs. Ensure your fuzz command uses `-fuzztime` to control individual test duration. + +## Permissions + +```yaml +permissions: + contents: read +``` diff --git a/docs/go-pr-analysis-workflow.md b/docs/go-pr-analysis-workflow.md index 5ccc7081..452dcca9 100644 --- a/docs/go-pr-analysis-workflow.md +++ b/docs/go-pr-analysis-workflow.md @@ -109,6 +109,10 @@ jobs: | `enable_coverage` | Enable coverage check with PR comment | No | `true` | | `enable_build` | Enable build verification | No | `true` | | `go_private_modules` | GOPRIVATE pattern for private Go modules (e.g., `github.com/LerianStudio/*`) | No | `''` | +| `enable_integration_tests` | Enable integration tests job | No | `false` | +| `integration_test_command` | Command to run integration tests | No | `make test-integration` | +| `enable_test_determinism` | Enable test determinism check (runs tests multiple times with shuffle) | No | `false` | +| `test_determinism_runs` | Number of times to run tests for determinism check | No | `3` | ### With Private Go Modules @@ -160,6 +164,12 @@ Calculates coverage and posts PR comment per changed app: ### build Verifies code compiles successfully per changed app. +### integration-tests +Runs integration tests per changed app using a configurable command (default: `make test-integration`). Disabled by default — enable with `enable_integration_tests: true`. + +### test-determinism +Runs unit tests multiple times with `-shuffle=on` to detect flaky or order-dependent tests. Always uses `go test` directly (bypasses Makefile) to guarantee shuffle flags are applied. Excludes `/tests/` and `/api/` packages. Disabled by default — enable with `enable_test_determinism: true`. + ### no-changes Runs when no Go changes are detected - outputs skip message. @@ -328,5 +338,5 @@ The workflow requires these permissions: --- -**Last Updated:** 2026-02-06 -**Version:** 1.2.0 +**Last Updated:** 2026-03-12 +**Version:** 1.3.0 diff --git a/docs/pr-security-scan-workflow.md b/docs/pr-security-scan-workflow.md index 10343715..101c1109 100644 --- a/docs/pr-security-scan-workflow.md +++ b/docs/pr-security-scan-workflow.md @@ -136,6 +136,33 @@ This will: - ✅ Run Trivy filesystem secret scanning - ❌ Skip Docker image build - ❌ Skip Docker vulnerability scanning +- ❌ Skip Docker Scout analysis + +### Docker Scout Analysis + +Enable Docker Scout for additional vulnerability scoring and CVE analysis on your Docker images: + +```yaml +name: PR Security Scan +on: + pull_request: + branches: [develop, release-candidate, main] + +jobs: + security-scan: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-security-scan.yml@v1.0.0 + with: + runner_type: "blacksmith-4vcpu-ubuntu-2404" + enable_docker_scout: true + secrets: inherit +``` + +This will run all standard scans plus Docker Scout quickview and CVE analysis. + +**Requirements:** +- Docker Hub account with Scout access (Free, Team, or Business) +- `DOCKER_USERNAME` and `DOCKER_PASSWORD` secrets configured +- `enable_docker_scan` must also be `true` (default) — Scout reuses the same image built for Trivy scanning ## Inputs @@ -150,6 +177,7 @@ This will: | `docker_registry` | string | `docker.io` | Docker registry URL | | `dockerfile_name` | string | `Dockerfile` | Name of the Dockerfile | | `enable_docker_scan` | boolean | `true` | Enable Docker image build and vulnerability scanning. Set to `false` for projects without Dockerfile (e.g., CLI tools) | +| `enable_docker_scout` | boolean | `false` | Enable Docker Scout image analysis for vulnerability scoring. Requires Docker Hub with Scout access | ## Secrets @@ -196,6 +224,7 @@ For each component in the matrix: 6. **Build Docker Image**: Build image for vulnerability scanning *(skipped if `enable_docker_scan: false`)* 7. **Trivy Vulnerability Scan (Table)**: Scan image for vulnerabilities *(skipped if `enable_docker_scan: false`)* 8. **Trivy Vulnerability Scan (SARIF)**: Generate SARIF report *(skipped if `enable_docker_scan: false`)* +9. **Docker Scout Analysis**: Quickview and CVE analysis *(skipped unless `enable_docker_scout: true` AND `enable_docker_scan: true`)* > **Note**: When `enable_docker_scan: false`, only filesystem secret scanning runs. This is useful for CLI tools and projects without Dockerfiles. diff --git a/docs/release-notification.md b/docs/release-notification.md new file mode 100644 index 00000000..535108b3 --- /dev/null +++ b/docs/release-notification.md @@ -0,0 +1,120 @@ + + + + + +
Lerian

release-notification

+ +Reusable workflow that sends release notifications to Discord and Slack. Fetches the latest release tag via GitHub CLI and dispatches to channel-specific composite actions. + +## Architecture + +``` +release-notification.yml + ├── src/notify/discord-release (SethCohen/github-releases-to-discord) + └── src/notify/slack-release (rtCamp/action-slack-notify) +``` + +## Inputs + +| Input | Type | Required | Default | Description | +|---|---|:---:|---|---| +| `product_name` | `string` | Yes | — | Product name displayed in notifications | +| `slack_channel` | `string` | No | `""` | Slack channel name | +| `discord_color` | `string` | No | `2105893` | Discord embed color (decimal) | +| `discord_username` | `string` | No | `Release Changelog` | Bot username in Discord | +| `discord_content` | `string` | No | `""` | Discord message content (e.g. role mentions) | +| `skip_beta_discord` | `boolean` | No | `true` | Skip Discord notification for beta releases | +| `slack_color` | `string` | No | `#36a64f` | Sidebar color for Slack message | +| `slack_icon_emoji` | `string` | No | `:rocket:` | Emoji icon for Slack bot | +| `dry_run` | `boolean` | No | `false` | Preview changes without sending notifications | + +## Secrets + +| Secret | Required | Description | +|---|---|---| +| `APP_ID` | Yes | GitHub App ID for authentication | +| `APP_PRIVATE_KEY` | Yes | GitHub App private key | +| `DISCORD_WEBHOOK_URL` | No | Discord webhook URL (skipped if empty) | +| `SLACK_WEBHOOK_URL` | No | Slack webhook URL (skipped if empty) | + +## Usage + +### Basic (Discord + Slack) + +```yaml +name: "Release Notifications" + +on: + release: + types: [published] + +jobs: + notify: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/release-notification.yml@v1.2.3 + with: + product_name: "Midaz" + slack_channel: "lerian-product-release" + discord_content: "<@&1346912737380274176>" + secrets: + APP_ID: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_PRIVATE_KEY }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.RELEASE_WEBHOOK_NOTIFICATION_URL }} +``` + +### Discord only + +```yaml +jobs: + notify: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/release-notification.yml@v1.2.3 + with: + product_name: "MyProduct" + discord_content: "<@&ROLE_ID>" + secrets: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} +``` + +### Slack only + +```yaml +jobs: + notify: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/release-notification.yml@v1.2.3 + with: + product_name: "MyProduct" + slack_channel: "releases" + secrets: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} +``` + +### Dry run (testing) + +```yaml +jobs: + notify: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/release-notification.yml@develop + with: + product_name: "MyProduct" + slack_channel: "test-channel" + dry_run: true + secrets: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} +``` + +## Permissions required + +```yaml +permissions: + contents: read +``` + +The GitHub App token handles elevated API access for fetching release information. diff --git a/docs/typescript-build.md b/docs/typescript-build.md new file mode 100644 index 00000000..87d77ebc --- /dev/null +++ b/docs/typescript-build.md @@ -0,0 +1,211 @@ + + + + + +
Lerian

typescript-build

+ +Reusable workflow for building and pushing Docker images from TypeScript/Node.js projects. Provides built-in `npmrc` authentication for GitHub Packages private `@lerianstudio` dependencies. + +The build logic is encapsulated in the [`docker-build-ts`](../src/build/docker-build-ts/) composite action. + +## Why Use This Instead of `build.yml`? + +| Feature | `build.yml` | `typescript-build.yml` | +|---------|-------------|------------------------| +| Default registry | DockerHub | GHCR | +| `npmrc` secret | Not included | Always injected automatically | +| `build_secrets` behavior | Replaces all secrets | Additive (extra secrets on top of npmrc) | +| `dry_run` mode | Not available | Available | +| `workflow_dispatch` | Not available | Available for manual testing | +| Dockerfile per component | Uses `dockerfile_name` only | Resolves `matrix.app.dockerfile` with fallback | + +## Usage + +### Basic Example (Single App) + +```yaml +name: Build Pipeline +on: + push: + tags: + - 'v*.*.*-beta.*' + - 'v*.*.*-rc.*' + - 'v[0-9]+.[0-9]+.[0-9]+' + +permissions: + contents: read + packages: write + +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/typescript-build.yml@v1.0.0 + secrets: inherit +``` + +### Multi-Component with Custom Dockerfiles + +```yaml +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/typescript-build.yml@v1.0.0 + with: + runner_type: firmino-lxc-runners + components_json: | + [ + {"name":"my-app","working_dir":".","dockerfile":"docker-app.Dockerfile"}, + {"name":"my-app-job","working_dir":".","dockerfile":"docker-job.Dockerfile"} + ] + secrets: inherit +``` + +### With Helm Dispatch + +```yaml +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/typescript-build.yml@v1.0.0 + with: + components_json: '[{"name":"my-app","working_dir":".","dockerfile":"docker-app.Dockerfile"}]' + enable_helm_dispatch: true + helm_chart: my-app + helm_target_ref: develop + helm_values_key_mappings: '{"my-app":"api"}' + secrets: inherit +``` + +### With Additional Build Secrets + +```yaml +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/typescript-build.yml@v1.0.0 + with: + build_secrets: | + custom_token=${{ secrets.CUSTOM_TOKEN }} + secrets: inherit +``` + +The `npmrc` secret is always injected automatically. `build_secrets` adds extra secrets on top of it. + +### Dry-Run Mode (Safe Testing) + +```yaml +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/typescript-build.yml@v1.0.0 + with: + dry_run: true + components_json: '[{"name":"my-app","working_dir":".","dockerfile":"Dockerfile"}]' + secrets: inherit +``` + +Builds the Docker image without pushing. Useful for validating Dockerfiles and build secrets. + +### Monorepo with Change Detection + +```yaml +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/typescript-build.yml@v1.0.0 + with: + filter_paths: | + apps/api + apps/worker + path_level: "2" + app_name_prefix: "my-project" + secrets: inherit +``` + +## Inputs + +| Input | Type | Default | Description | +|-------|------|---------|-------------| +| `runner_type` | string | `blacksmith-4vcpu-ubuntu-2404` | Runner to use | +| `dry_run` | boolean | `false` | Preview changes without pushing images | +| `filter_paths` | string | `''` | Path prefixes for monorepo change detection | +| `path_level` | string | `2` | Limits the path to the first N segments | +| `enable_dockerhub` | boolean | `false` | Enable pushing to DockerHub | +| `enable_ghcr` | boolean | `true` | Enable pushing to GHCR | +| `dockerhub_org` | string | `lerianstudio` | DockerHub organization name | +| `ghcr_org` | string | `''` | GHCR organization name (defaults to repo owner) | +| `dockerfile_name` | string | `Dockerfile` | Default Dockerfile name (overridden by `matrix.app.dockerfile`) | +| `app_name_prefix` | string | `''` | Prefix for app names in monorepo | +| `app_name_overrides` | string | `''` | Explicit app name mappings | +| `build_context` | string | `.` | Docker build context | +| `build_secrets` | string | `''` | Additional secrets (npmrc is always included) | +| `enable_gitops_artifacts` | boolean | `false` | Enable GitOps artifacts upload | +| `components_json` | string | `''` | Explicit JSON array of components to build | +| `normalize_to_filter` | boolean | `true` | Normalize changed paths to filter path | +| `enable_helm_dispatch` | boolean | `false` | Enable Helm repository dispatch | +| `helm_repository` | string | `LerianStudio/helm` | Helm repository (org/repo) | +| `helm_chart` | string | `''` | Helm chart name to update | +| `helm_target_ref` | string | `main` | Target branch in Helm repository | +| `helm_components_base_path` | string | `components` | Base path for components | +| `helm_env_file` | string | `.env.example` | Env example file name | +| `helm_detect_env_changes` | boolean | `true` | Detect new env variables for Helm | +| `helm_dispatch_on_rc` | boolean | `false` | Enable Helm dispatch for rc tags | +| `helm_dispatch_on_beta` | boolean | `false` | Enable Helm dispatch for beta tags | +| `helm_values_key_mappings` | string | `''` | Component names to values.yaml keys mapping | + +## Secrets + +| Secret | Required | Description | +|--------|----------|-------------| +| `MANAGE_TOKEN` | Yes | GitHub token for GHCR login and npmrc authentication | +| `DOCKER_USERNAME` | If DockerHub enabled | DockerHub username | +| `DOCKER_PASSWORD` | If DockerHub enabled | DockerHub password | +| `HELM_REPO_TOKEN` | If Helm dispatch enabled | Token with access to Helm repository | +| `SLACK_WEBHOOK_URL` | No | Slack webhook for build notifications | + +## Architecture + +``` +typescript-build.yml (reusable workflow) + ├── prepare job → matrix, platforms, version + ├── build job → calls src/build/docker-build-ts composite + ├── notify job → calls slack-notify.yml + └── dispatch-helm job → calls dispatch-helm.yml +``` + +## Jobs + +### prepare + +Determines the build matrix and platform strategy: +- **Single app mode** (default): builds from repository root +- **Explicit components mode**: uses `components_json` directly +- **Monorepo mode**: detects changed paths via `filter_paths` +- **Platform strategy**: `linux/amd64` for beta/rc, `linux/amd64,linux/arm64` for releases + +### build + +Runs the `docker-build-ts` composite for each component in the matrix. Also handles GitOps artifact creation when enabled. + +### notify + +Sends Slack notification with build status. Skipped during dry run. + +### dispatch-helm + +Dispatches to Helm repository for chart updates. Only runs on successful non-dry-run builds. + +## Dockerfile Requirements + +Dockerfiles must mount the `npmrc` secret for installing private packages: + +```dockerfile +RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm install +``` + +## Related Workflows + +- [`build.yml`](build.md) — Generic Docker build workflow (Go-oriented defaults) +- [`typescript-ci.yml`](typescript-ci.md) — TypeScript continuous integration +- [`typescript-release.yml`](typescript-release-workflow.md) — TypeScript semantic release +- [`src/build/docker-build-ts`](../src/build/docker-build-ts/) — Composite action used by this workflow + +--- + +**Last Updated:** 2026-03-09 +**Version:** 1.0.0 diff --git a/src/build/docker-build-ts/README.md b/src/build/docker-build-ts/README.md new file mode 100644 index 00000000..e6baff6b --- /dev/null +++ b/src/build/docker-build-ts/README.md @@ -0,0 +1,80 @@ + + + + + +
Lerian

docker-build-ts

+ +Composite action that builds and pushes a Docker image for a single TypeScript/Node.js component. Automatically injects an `npmrc` secret for GitHub Packages `@lerianstudio` private dependencies. + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `enable-dockerhub` | Enable pushing to DockerHub | No | `false` | +| `enable-ghcr` | Enable pushing to GHCR | No | `true` | +| `dockerhub-org` | DockerHub organization name | No | `lerianstudio` | +| `ghcr-org` | GHCR organization name (defaults to repo owner) | No | `""` | +| `dockerhub-username` | DockerHub username | If DockerHub enabled | `""` | +| `dockerhub-password` | DockerHub password | If DockerHub enabled | `""` | +| `ghcr-token` | Token for GHCR login and npmrc authentication | Yes | — | +| `app-name` | Image name for this component | Yes | — | +| `working-dir` | Working directory for this component | No | `.` | +| `dockerfile` | Dockerfile path relative to working-dir | No | `""` | +| `dockerfile-name` | Default Dockerfile name when `dockerfile` is not set | No | `Dockerfile` | +| `build-context` | Docker build context | No | `.` | +| `build-secrets` | Additional secrets (one per line). npmrc is always included. | No | `""` | +| `platforms` | Target platforms | No | `linux/amd64` | +| `version` | Semver version from tag (e.g., `v1.0.0-beta.1`) | Yes | — | +| `is-release` | Whether this is a production release | No | `false` | +| `dry-run` | Build without pushing | No | `false` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `image-digest` | Digest of the pushed image | +| `image-tags` | Tags applied to the image | + +## Usage as composite step + +```yaml +steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and push + uses: ./src/build/docker-build-ts + with: + ghcr-token: ${{ secrets.MANAGE_TOKEN }} + app-name: my-app + version: v1.0.0-beta.1 + platforms: linux/amd64 +``` + +## Usage via reusable workflow + +```yaml +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/typescript-build.yml@v1.0.0 + with: + components_json: '[{"name":"my-app","working_dir":".","dockerfile":"Dockerfile"}]' + secrets: inherit +``` + +## Required permissions + +```yaml +permissions: + contents: read + packages: write +``` + +## Dockerfile requirements + +Dockerfiles must mount the `npmrc` secret for installing private packages: + +```dockerfile +RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm install +``` diff --git a/src/build/docker-build-ts/action.yml b/src/build/docker-build-ts/action.yml new file mode 100644 index 00000000..0f1cd0ce --- /dev/null +++ b/src/build/docker-build-ts/action.yml @@ -0,0 +1,224 @@ +name: Docker Build & Push (TypeScript) +description: Build and push a Docker image for a TypeScript/Node.js component with built-in npmrc authentication for GitHub Packages. + +inputs: + # Registry + enable-dockerhub: + description: Enable pushing to DockerHub + required: false + default: "false" + enable-ghcr: + description: Enable pushing to GHCR + required: false + default: "true" + dockerhub-org: + description: DockerHub organization name + required: false + default: "lerianstudio" + ghcr-org: + description: GHCR organization name (defaults to repository owner) + required: false + default: "" + # Credentials + dockerhub-username: + description: DockerHub username + required: false + default: "" + dockerhub-password: + description: DockerHub password + required: false + default: "" + ghcr-token: + description: Token for GHCR login and npmrc authentication + required: true + # Build + app-name: + description: Image name for this component + required: true + working-dir: + description: Working directory for this component + required: false + default: "." + dockerfile: + description: Dockerfile path relative to working-dir (overrides dockerfile-name) + required: false + default: "" + dockerfile-name: + description: Default Dockerfile name when dockerfile is not set + required: false + default: "Dockerfile" + build-context: + description: Docker build context + required: false + default: "." + build-secrets: + description: Additional secrets for docker build (one per line). npmrc is always included. + required: false + default: "" + platforms: + description: Target platforms (e.g., linux/amd64 or linux/amd64,linux/arm64) + required: false + default: "linux/amd64" + version: + description: Semver version extracted from tag (e.g., v1.0.0-beta.1) + required: true + is-release: + description: Whether this is a production release (enables major tag) + required: false + default: "false" + # Mode + dry-run: + description: Build without pushing + required: false + default: "false" + +outputs: + image-digest: + description: Digest of the pushed image + value: ${{ steps.build-push.outputs.digest || steps.build-dry.outputs.digest || '' }} + image-tags: + description: Tags applied to the image + value: ${{ steps.meta.outputs.tags }} + +runs: + using: composite + steps: + # ----------------- Setup ----------------- + - name: Set up QEMU + if: ${{ contains(inputs.platforms, 'arm64') }} + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to DockerHub + if: ${{ inputs.enable-dockerhub == 'true' }} + uses: docker/login-action@v4 + with: + username: ${{ inputs.dockerhub-username }} + password: ${{ inputs.dockerhub-password }} + + - name: Log in to GHCR + if: ${{ inputs.enable-ghcr == 'true' }} + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ inputs.ghcr-token }} + + # ----------------- Image Configuration ----------------- + - name: Normalize repository owner to lowercase + id: normalize + shell: bash + run: | + echo "owner_lower=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + - name: Set image names + id: image-names + shell: bash + run: | + IMAGES="" + GHCR_ORG="${{ inputs.ghcr-org }}" + if [ -z "$GHCR_ORG" ]; then + GHCR_ORG="${{ steps.normalize.outputs.owner_lower }}" + else + GHCR_ORG=$(echo "$GHCR_ORG" | tr '[:upper:]' '[:lower:]') + fi + + if [ "${{ inputs.enable-dockerhub }}" == "true" ]; then + IMAGES="${{ inputs.dockerhub-org }}/${{ inputs.app-name }}" + fi + + if [ "${{ inputs.enable-ghcr }}" == "true" ]; then + if [ -n "$IMAGES" ]; then + IMAGES="${IMAGES},ghcr.io/${GHCR_ORG}/${{ inputs.app-name }}" + else + IMAGES="ghcr.io/${GHCR_ORG}/${{ inputs.app-name }}" + fi + fi + + echo "images=$IMAGES" >> $GITHUB_OUTPUT + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ steps.image-names.outputs.images }} + tags: | + type=semver,pattern={{version}},value=${{ inputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ inputs.version }} + type=semver,pattern={{major}},value=${{ inputs.version }},enable=${{ inputs.is-release }} + + - name: Resolve Dockerfile path + id: dockerfile + shell: bash + run: | + DOCKERFILE="${{ inputs.dockerfile }}" + if [ -z "$DOCKERFILE" ]; then + DOCKERFILE="${{ inputs.dockerfile-name }}" + fi + echo "path=${{ inputs.working-dir }}/$DOCKERFILE" >> $GITHUB_OUTPUT + + - name: Resolve build secrets + id: secrets + shell: bash + run: | + NPMRC_SECRET="npmrc=//npm.pkg.github.com/:_authToken=${{ inputs.ghcr-token }}" + + EXTRA_SECRETS='${{ inputs.build-secrets }}' + if [ -n "$EXTRA_SECRETS" ]; then + { + echo "value<> $GITHUB_OUTPUT + else + { + echo "value<> $GITHUB_OUTPUT + fi + + # ----------------- Dry Run ----------------- + - name: Dry run summary + if: ${{ inputs.dry-run == 'true' }} + shell: bash + run: | + echo "::notice::DRY RUN — no images will be pushed" + echo " component : ${{ inputs.app-name }}" + echo " dockerfile : ${{ steps.dockerfile.outputs.path }}" + echo " context : ${{ inputs.build-context }}" + echo " platforms : ${{ inputs.platforms }}" + echo " tags : ${{ steps.meta.outputs.tags }}" + echo " registries : ${{ steps.image-names.outputs.images }}" + + - name: Build Docker image (dry run) + if: ${{ inputs.dry-run == 'true' }} + id: build-dry + uses: docker/build-push-action@v7 + with: + context: ${{ inputs.build-context }} + file: ${{ steps.dockerfile.outputs.path }} + platforms: linux/amd64 + push: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + secrets: ${{ steps.secrets.outputs.value }} + + # ----------------- Build & Push ----------------- + - name: Build and push Docker image + if: ${{ inputs.dry-run != 'true' }} + id: build-push + uses: docker/build-push-action@v7 + with: + context: ${{ inputs.build-context }} + file: ${{ steps.dockerfile.outputs.path }} + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + secrets: ${{ steps.secrets.outputs.value }} diff --git a/src/config/branch-cleanup/action.yml b/src/config/branch-cleanup/action.yml index 2b04c793..8b608d5a 100644 --- a/src/config/branch-cleanup/action.yml +++ b/src/config/branch-cleanup/action.yml @@ -25,6 +25,7 @@ inputs: runs: using: composite steps: + # ----------------- Merged Branch Mode ----------------- - name: Delete merged branch if: ${{ inputs.merged-branch != '' }} shell: bash @@ -60,6 +61,7 @@ runs: && echo "::notice::Deleted merged branch '$MERGED_BRANCH'" \ || echo "::warning::Branch '$MERGED_BRANCH' not found or already deleted" + # ----------------- Stale Branch Mode ----------------- - name: Scan and delete stale branches if: ${{ inputs.merged-branch == '' }} shell: bash diff --git a/src/config/changed-paths/README.md b/src/config/changed-paths/README.md new file mode 100644 index 00000000..6cdbb375 --- /dev/null +++ b/src/config/changed-paths/README.md @@ -0,0 +1,215 @@ + + + + + +
Lerian

changed-paths

+ +Composite action that detects changed files between commits and outputs a matrix of changed directories. Designed for monorepo setups to trigger builds only for components that have changed. + +## Inputs + +> **Breaking change (kebab-case):** All input names were renamed from `snake_case` to `kebab-case`. Update any callers that used the old names: +> +> | Old (snake_case) | New (kebab-case) | +> |---|---| +> | `filter_paths` | `filter-paths` | +> | `shared_paths` | `shared-paths` | +> | `path_level` | `path-level` | +> | `get_app_name` | `get-app-name` | +> | `app_name_prefix` | `app-name-prefix` | +> | `app_name_overrides` | `app-name-overrides` | +> | `normalize_to_filter` | `normalize-to-filter` | +> | `ignore_dirs` | `ignore-dirs` | +> | `fallback_app_name` | `fallback-app-name` | +> | `consolidate_to_root` | `consolidate-to-root` | +> | `consolidate_keep_dirs` | `consolidate-keep-dirs` | + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `filter-paths` | Newline-separated list of path prefixes to filter results. Also accepts JSON array format. | No | `''` | +| `shared-paths` | Newline-separated (or JSON array) path patterns that, when matched by any changed file, include ALL `filter-paths` components in the matrix (e.g., `go.mod`, `go.sum`, `libs/`) | No | `''` | +| `path-level` | Limits the path to the first N segments (0 = disabled) | No | `0` | +| `get-app-name` | Output matrix with `name` and `working_dir` fields | No | `false` | +| `app-name-prefix` | Prefix to add to each app name | No | `''` | +| `app-name-overrides` | Newline-separated `path:name` mappings. Use `path:` for prefix-only | No | `''` | +| `normalize-to-filter` | Use filter path as `working_dir` instead of actual trimmed path | No | `false` | +| `ignore-dirs` | Newline-separated directories to exclude from the output matrix | No | `''` | +| `fallback-app-name` | When `filter-paths` is empty, return single-item matrix with this name | No | `''` | +| `consolidate-to-root` | Consolidate all entries (except `consolidate-keep-dirs`) to root | No | `false` | +| `consolidate-keep-dirs` | Newline-separated dirs to keep as-is during consolidation | No | `''` | + +## Outputs + +| Output | Description | +|---|---| +| `matrix` | JSON array of changed directories (or objects with `name` and `working_dir`) | +| `has_changes` | `'true'` or `'false'` indicating if changes were detected | + +## Usage as composite step + +```yaml +steps: + - name: Get changed paths + id: changed-paths + uses: LerianStudio/github-actions-shared-workflows/src/config/changed-paths@v1.0.0 + with: + filter-paths: '["components/api", "components/web"]' + path-level: 2 + get-app-name: true + app-name-prefix: 'myapp' +``` + +## Output formats + +### Default (get-app-name: false) + +```json +["components/api", "components/web"] +``` + +### With app names (get-app-name: true) + +```json +[ + {"name": "api", "working_dir": "components/api"}, + {"name": "web", "working_dir": "components/web"} +] +``` + +### With prefix (app-name-prefix: "myapp") + +```json +[ + {"name": "myapp-api", "working_dir": "components/api"}, + {"name": "myapp-web", "working_dir": "components/web"} +] +``` + +### With overrides + +```yaml +with: + app-name-overrides: |- + components/onboarding: + components/transaction:tx + app-name-prefix: 'midaz' +``` + +```json +[ + {"name": "midaz", "working_dir": "components/onboarding"}, + {"name": "midaz-tx", "working_dir": "components/transaction"} +] +``` + +### With ignore-dirs + +```yaml +with: + filter-paths: |- + components/api + components/web + ignore-dirs: |- + .github + .githooks + get-app-name: true +``` + +Directories matching `.github` or `.githooks` (exact or prefix) are excluded from the output matrix before app name generation. + +### Single app mode (fallback-app-name) + +When `filter-paths` is empty and `fallback-app-name` is set, the composite skips change detection and returns a single-item matrix: + +```yaml +with: + get-app-name: true + fallback-app-name: 'my-service' +``` + +```json +[{"name": "my-service", "working_dir": "."}] +``` + +### Type 2 monorepo (consolidate-to-root) + +When `consolidate-to-root: true`, all entries except those matching `consolidate-keep-dirs` are consolidated into a single root entry using `fallback-app-name`: + +```yaml +with: + filter-paths: |- + components/api + components/worker + frontend + get-app-name: true + fallback-app-name: 'my-repo' + consolidate-to-root: true + consolidate-keep-dirs: 'frontend' + ignore-dirs: |- + .github + .githooks +``` + +If `components/api` and `frontend` both changed: + +```json +[ + {"name": "my-repo", "working_dir": "."}, + {"name": "frontend", "working_dir": "frontend"} +] +``` + +### With shared-paths (monorepo root-level files) + +When root-level files like `go.mod` or `go.sum` change, all components should be rebuilt. Use `shared-paths` to trigger a full matrix whenever such files are touched: + +```yaml +with: + filter-paths: |- + components/manager + components/worker + shared-paths: |- + go.mod + go.sum + libs/ + path-level: 2 + get-app-name: true +``` + +If only `go.mod` changes → both `components/manager` and `components/worker` are included in the matrix. +If only `components/worker/cmd/main.go` changes → only `components/worker` is included (normal behaviour). + +### With normalize-to-filter + +When `normalize-to-filter: true`, deeper changed paths are normalized back to the matching filter path. + +Changed file `components/app/cmd/main.go` with `filter-paths: '["components/app"]'` outputs `working_dir: "components/app"` instead of `components/app/cmd`. + +## How path-level works + +| Original Path | path-level | Result | +|---|---|---| +| `components/api/src/main.go` | 1 | `components` | +| `components/api/src/main.go` | 2 | `components/api` | +| `components/api/src/main.go` | 3 | `components/api/src` | +| `services/auth/handlers/login.ts` | 2 | `services/auth` | + +## Event support + +| Event | Diff strategy | +|---|---| +| `pull_request` / `pull_request_target` | Base SHA vs HEAD | +| `push` | `before` SHA vs `sha` | +| Tag / first commit | `HEAD^` vs HEAD (fallback: `ls-tree`) | + +## Required permissions + +```yaml +permissions: + contents: read +``` + +## Requirements + +This action uses `jq` for JSON processing, which is preinstalled on all GitHub-hosted runners. diff --git a/src/config/changed-paths/action.yml b/src/config/changed-paths/action.yml new file mode 100644 index 00000000..9e80598d --- /dev/null +++ b/src/config/changed-paths/action.yml @@ -0,0 +1,356 @@ +name: Get Changed Paths +description: Detects changed files between commits and outputs a matrix of changed directories for monorepo CI/CD pipelines. + +inputs: + filter-paths: + description: 'Newline-separated list of path prefixes to filter results (e.g., "components/mdz\ncomponents/transaction"). Also accepts JSON array format.' + required: false + default: '' + shared-paths: + description: 'Newline-separated list of path patterns (e.g., "go.mod\ngo.sum\nlibs/") that, when matched by any changed file, trigger a build for ALL components in filter_paths. Also accepts JSON array format.' + required: false + default: '' + path-level: + description: 'Limits the path to the first N segments (e.g., 2 -> "components/transactions")' + required: false + default: '0' + get-app-name: + description: 'If true, outputs a matrix of objects with app name and working directory. Otherwise, outputs a list of changed directories' + required: false + default: 'false' + app-name-prefix: + description: 'Prefix to add to each app name when get_app_name is true' + required: false + default: '' + app-name-overrides: + description: 'Newline-separated list of explicit app name mappings in "path:name" format. Use "path:" for prefix-only. Overrides default segment extraction' + required: false + default: '' + normalize-to-filter: + description: 'If true, uses the filter path as working_dir instead of the actual trimmed directory path' + required: false + default: 'false' + ignore-dirs: + description: 'Newline-separated list of directories to exclude from the output matrix (e.g., ".github\n.githooks"). Matched by exact name or prefix.' + required: false + default: '' + fallback-app-name: + description: 'When filter_paths is empty, return a single-item matrix with this name and working_dir "." instead of detecting changes. Enables single-app mode.' + required: false + default: '' + consolidate-to-root: + description: 'When true, consolidate all entries (except those in consolidate_keep_dirs) to a single root entry using fallback_app_name. Requires get_app_name and fallback_app_name.' + required: false + default: 'false' + consolidate-keep-dirs: + description: 'Newline-separated list of working_dirs to keep as-is during consolidation (e.g., "frontend"). Only used when consolidate_to_root is true.' + required: false + default: '' + +outputs: + matrix: + description: 'JSON array of changed directories (or objects with name and working_dir if get_app_name is true)' + value: ${{ steps.dirs.outputs.matrix }} + has_changes: + description: 'Boolean indicating if there are any changes' + value: ${{ steps.dirs.outputs.has_changes }} + +runs: + using: composite + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get changed files + id: changed + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" || "${{ github.event_name }}" == "pull_request_target" ]]; then + # For PRs, diff between the base branch and current HEAD + FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} HEAD) + elif [[ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ]] || [[ -z "${{ github.event.before }}" ]]; then + # Tag push or missing before ref + CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "") + if [[ -n "$CURRENT_TAG" ]]; then + # Detect channel from tag name: beta, rc, or release + if [[ "$CURRENT_TAG" == *"-beta"* ]]; then + CHANNEL="beta" + elif [[ "$CURRENT_TAG" == *"-rc"* ]]; then + CHANNEL="rc" + else + CHANNEL="release" + fi + + # Find previous tag of the same channel to avoid cross-channel false negatives + # (e.g. rc.1 vs beta.N where beta is a parent commit → empty diff) + if [[ "$CHANNEL" == "release" ]]; then + PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | grep -v -- "-beta" | grep -v -- "-rc" | head -1 || true) + else + PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | grep -- "-${CHANNEL}" | head -1 || true) + fi + + if [[ -n "$PREV_TAG" ]]; then + echo "Tag push detected ($CHANNEL channel): comparing $PREV_TAG..$CURRENT_TAG" + FILES=$(git diff --name-only "$PREV_TAG" HEAD) + else + # First tag of this channel — build everything + echo "First tag for channel '$CHANNEL': building all files" + FILES=$(git ls-tree -r --name-only HEAD) + fi + else + # Not a tag — compare with previous commit + PREV_COMMIT=$(git rev-parse HEAD^) + if [[ $? -eq 0 ]]; then + FILES=$(git diff --name-only $PREV_COMMIT HEAD) + else + # Fallback for first commit + FILES=$(git ls-tree -r --name-only HEAD) + fi + fi + else + # Normal case - diff between commits + FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }}) + fi + printf "files<> "$GITHUB_OUTPUT" + + - name: Extract changed directories + id: dirs + shell: bash + run: | + FILES="${{ steps.changed.outputs.files }}" + FILTER_PATHS='${{ inputs.filter-paths }}' + SHARED_PATHS='${{ inputs.shared-paths }}' + PATH_LEVEL="${{ inputs.path-level }}" + GET_APP_NAME="${{ inputs.get-app-name }}" + APP_NAME_PREFIX="${{ inputs.app-name-prefix }}" + APP_NAME_OVERRIDES="${{ inputs.app-name-overrides }}" + NORMALIZE_TO_FILTER="${{ inputs.normalize-to-filter }}" + IGNORE_DIRS='${{ inputs.ignore-dirs }}' + FALLBACK_APP_NAME="${{ inputs.fallback-app-name }}" + CONSOLIDATE_TO_ROOT="${{ inputs.consolidate-to-root }}" + CONSOLIDATE_KEEP_DIRS='${{ inputs.consolidate-keep-dirs }}' + + # Single app fallback: when filter_paths is empty and fallback_app_name is set, + # return a single-item matrix without detecting changes + if [[ ( -z "$FILTER_PATHS" || "$FILTER_PATHS" == "[]" ) && -n "$FALLBACK_APP_NAME" ]]; then + echo "Single app mode: using fallback name '$FALLBACK_APP_NAME'" + printf 'matrix=[{"name":"%s","working_dir":"."}]\n' "$FALLBACK_APP_NAME" >> "$GITHUB_OUTPUT" + printf "has_changes=true\n" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Parse app_name_overrides into associative array + declare -A NAME_OVERRIDES + if [[ -n "$APP_NAME_OVERRIDES" ]]; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + OVERRIDE_PATH="${line%%:*}" + OVERRIDE_NAME="${line#*:}" + NAME_OVERRIDES["$OVERRIDE_PATH"]="$OVERRIDE_NAME" + done <<< "$APP_NAME_OVERRIDES" + fi + + if [[ -z "$FILES" ]]; then + echo "No files changed." + printf "matrix=[]\n" >> "$GITHUB_OUTPUT" + printf "has_changes=false\n" >> "$GITHUB_OUTPUT" + exit 0 + fi + + DIRS_FROM_SHARED=false + + # Check shared_paths: if any changed file matches, include ALL filter_paths entries + if [[ -n "$SHARED_PATHS" ]] && [[ "$SHARED_PATHS" != "[]" ]] && [[ -n "$FILTER_PATHS" ]] && [[ "$FILTER_PATHS" != "[]" ]]; then + if [[ "$SHARED_PATHS" == "["* ]]; then + SHARED_LIST=$(echo "$SHARED_PATHS" | jq -er '.[]' 2>/dev/null) + if [[ $? -ne 0 ]]; then + echo "::error::shared_paths looks like a JSON array but is malformed: $SHARED_PATHS" + exit 1 + fi + else + SHARED_LIST="$SHARED_PATHS" + fi + + while IFS= read -r FILE; do + [[ -z "$FILE" ]] && continue + while IFS= read -r SHARED; do + [[ -z "$SHARED" ]] && continue + while [[ "$SHARED" == */ && "$SHARED" != "/" ]]; do SHARED="${SHARED%/}"; done + if [[ "$FILE" == "$SHARED" ]] || [[ "$FILE" == "$SHARED"/* ]]; then + echo "Shared path hit: '$FILE' matches '$SHARED' — including all filter_paths" + DIRS_FROM_SHARED=true + break 2 + fi + done <<< "$SHARED_LIST" + done <<< "$FILES" + fi + + if [[ "$DIRS_FROM_SHARED" == "true" ]]; then + # Use all filter_paths as the build targets + if [[ "$FILTER_PATHS" == "["* ]]; then + DIRS=$(echo "$FILTER_PATHS" | jq -er '.[]' 2>/dev/null) + if [[ $? -ne 0 ]]; then + echo "::error::filter-paths looks like a JSON array but is malformed: $FILTER_PATHS" + exit 1 + fi + else + DIRS="$FILTER_PATHS" + fi + else + # Get directory for each file + DIRS=$(echo "$FILES" | xargs -n1 dirname) + + # Trim to first N path segments if specified + if [[ -n "$PATH_LEVEL" ]] && [[ "$PATH_LEVEL" -gt 0 ]]; then + echo "Trimming paths to first $PATH_LEVEL segments" + DIRS=$(echo "$DIRS" | cut -d'/' -f-"$PATH_LEVEL") + fi + + # Filter paths if filter_paths is provided (supports both JSON array and newline-separated formats) + if [[ -n "$FILTER_PATHS" ]] && [[ "$FILTER_PATHS" != "[]" ]] && [[ "$FILTER_PATHS" != "" ]]; then + # Detect format and parse accordingly + if [[ "$FILTER_PATHS" == "["* ]]; then + # Input looks like JSON array — validate strictly + FILTER_LIST=$(echo "$FILTER_PATHS" | jq -er '.[]' 2>/dev/null) + if [[ $? -ne 0 ]]; then + echo "::error::filter-paths looks like a JSON array but is malformed: $FILTER_PATHS" + exit 1 + fi + else + # Newline-separated input + FILTER_LIST="$FILTER_PATHS" + fi + + if [[ -n "$FILTER_LIST" ]]; then + FILTERED="" + while read -r DIR; do + while read -r FILTER; do + [[ -z "$FILTER" ]] && continue + while [[ "$FILTER" == */ && "$FILTER" != "/" ]]; do + FILTER="${FILTER%/}" + done + if [[ "$DIR" == "$FILTER" ]] || [[ "$DIR" == "$FILTER"/* ]]; then + if [[ "$NORMALIZE_TO_FILTER" == "true" ]]; then + FILTERED+="$FILTER"$'\n' + else + FILTERED+="$DIR"$'\n' + fi + break + fi + done <<< "$FILTER_LIST" + done <<< "$DIRS" + + # If nothing matched, exit + if [[ -z "$FILTERED" ]]; then + echo "No matching directories found after filtering." + printf "matrix=[]\n" >> "$GITHUB_OUTPUT" + printf "has_changes=false\n" >> "$GITHUB_OUTPUT" + exit 0 + fi + + DIRS="$FILTERED" + fi + fi + fi + + # Deduplicate and remove empty lines + DIRS=$(echo "$DIRS" | grep -v '^$' | sort -u) + + # Exclude ignored directories + if [[ -n "$IGNORE_DIRS" ]]; then + KEPT="" + while read -r DIR; do + SHOULD_IGNORE=false + while IFS= read -r IGNORE; do + [[ -z "$IGNORE" ]] && continue + if [[ "$DIR" == "$IGNORE" ]] || [[ "$DIR" == "$IGNORE"/* ]]; then + SHOULD_IGNORE=true + break + fi + done <<< "$IGNORE_DIRS" + if [[ "$SHOULD_IGNORE" == "false" ]]; then + KEPT+="$DIR"$'\n' + fi + done <<< "$DIRS" + DIRS=$(echo "$KEPT" | grep -v '^$') + fi + + # Check if we have any directories + if [[ -z "$DIRS" ]]; then + echo "No directories found." + printf "matrix=[]\n" >> "$GITHUB_OUTPUT" + printf "has_changes=false\n" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "$GET_APP_NAME" == "true" ]]; then + echo "Generating object matrix with app names" + MATRIX="[" + FIRST=true + while read -r DIR; do + # Check if there's an explicit override for this path + if [[ -n "${NAME_OVERRIDES[$DIR]+set}" ]]; then + OVERRIDE_VALUE="${NAME_OVERRIDES[$DIR]}" + if [[ -z "$OVERRIDE_VALUE" ]]; then + APP_NAME="$APP_NAME_PREFIX" + else + if [[ -n "$APP_NAME_PREFIX" ]]; then + APP_NAME="${APP_NAME_PREFIX}-${OVERRIDE_VALUE}" + else + APP_NAME="$OVERRIDE_VALUE" + fi + fi + else + # No override - extract app name from second path segment + EXTRACTED_NAME="$(echo "$DIR" | cut -d'/' -f2)" + if [[ -n "$APP_NAME_PREFIX" ]]; then + APP_NAME="${APP_NAME_PREFIX}-${EXTRACTED_NAME}" + else + APP_NAME="$EXTRACTED_NAME" + fi + fi + + ENTRY="{\"name\":\"$APP_NAME\",\"working_dir\":\"$DIR\"}" + if $FIRST; then + MATRIX+="$ENTRY" + FIRST=false + else + MATRIX+=",$ENTRY" + fi + done <<< "$DIRS" + MATRIX+="]" + else + # Default: return just an array of paths + MATRIX=$(echo "$DIRS" | jq -Rc . | jq -sc .) + fi + + # Consolidate to root (type2 monorepo support): + # entries not in consolidate_keep_dirs are replaced with a single root entry + if [[ "$CONSOLIDATE_TO_ROOT" == "true" && "$GET_APP_NAME" == "true" && -n "$FALLBACK_APP_NAME" ]]; then + KEEP_PATTERN="" + while IFS= read -r KEEP_DIR; do + [[ -z "$KEEP_DIR" ]] && continue + if [[ -n "$KEEP_PATTERN" ]]; then + KEEP_PATTERN+=",$KEEP_DIR" + else + KEEP_PATTERN="$KEEP_DIR" + fi + done <<< "$CONSOLIDATE_KEEP_DIRS" + + MATRIX=$(echo "$MATRIX" | jq -c --arg name "$FALLBACK_APP_NAME" --arg keep "$KEEP_PATTERN" ' + ($keep | split(",") | map(select(. != ""))) as $keep_list | + map( + if (.working_dir as $wd | $keep_list | any(. == $wd)) then + . + else + {"name": $name, "working_dir": "."} + end + ) | unique_by(.working_dir) + ') + fi + + echo "Changed directories matrix: $MATRIX" + printf "matrix=%s\n" "$MATRIX" >> "$GITHUB_OUTPUT" + printf "has_changes=true\n" >> "$GITHUB_OUTPUT" diff --git a/src/config/changed-workflows/README.md b/src/config/changed-workflows/README.md new file mode 100644 index 00000000..3a07a537 --- /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 00000000..8a637f8a --- /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/config/labels-sync/action.yml b/src/config/labels-sync/action.yml index 84bb8f6b..9fd08559 100644 --- a/src/config/labels-sync/action.yml +++ b/src/config/labels-sync/action.yml @@ -22,7 +22,7 @@ runs: using: composite steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Sync labels uses: crazy-max/ghaction-github-labeler@v5 diff --git a/src/lint/actionlint/README.md b/src/lint/actionlint/README.md new file mode 100644 index 00000000..beac24be --- /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 00000000..9caa866b --- /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 00000000..1095b46b --- /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 00000000..0815e5c1 --- /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 00000000..ffc426ce --- /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 00000000..64e51018 --- /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 00000000..02578de9 --- /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 00000000..1d11075b --- /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 00000000..466c02d5 --- /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 00000000..bf81306a --- /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 00000000..5f669b4a --- /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 00000000..d20b65ad --- /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 00000000..b75e900a --- /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 00000000..fadabed7 --- /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 00000000..3400d805 --- /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 00000000..a6d14c4f --- /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/discord-release/README.md b/src/notify/discord-release/README.md new file mode 100644 index 00000000..566bec12 --- /dev/null +++ b/src/notify/discord-release/README.md @@ -0,0 +1,67 @@ + + + + + +
Lerian

discord-release

+ +Composite action that sends a release notification to a Discord channel via webhook. Wraps [SethCohen/github-releases-to-discord](https://github.com/SethCohen/github-releases-to-discord). + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `webhook-url` | Discord webhook URL | Yes | — | +| `release-tag` | Release tag (e.g. `v1.2.3` or `v1.0.0-beta.1`) | Yes | — | +| `color` | Embed color (decimal) | No | `2105893` | +| `username` | Bot username displayed in Discord | No | `Release Changelog` | +| `content` | Message content (e.g. role mentions) | No | `""` | +| `footer-timestamp` | Show timestamp in embed footer | No | `true` | +| `skip-beta` | Skip notification for beta releases | No | `true` | +| `dry-run` | Preview changes without sending the notification | No | `false` | + +## Usage + +### As a composite action (inline step) + +```yaml +jobs: + notify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@v1.2.3 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + release-tag: ${{ github.event.release.tag_name }} + content: "<@&1234567890>" +``` + +### As a reusable workflow (recommended) + +```yaml +jobs: + notify: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/release-notification.yml@v1.2.3 + with: + product_name: "MyProduct" + discord_content: "<@&1234567890>" + secrets: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} +``` + +### Dry run (preview only) + +```yaml +- uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@develop + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + release-tag: ${{ github.event.release.tag_name }} + dry-run: "true" +``` + +## Permissions required + +No special permissions required beyond the webhook URL secret. diff --git a/src/notify/discord-release/action.yml b/src/notify/discord-release/action.yml new file mode 100644 index 00000000..60fd0002 --- /dev/null +++ b/src/notify/discord-release/action.yml @@ -0,0 +1,83 @@ +name: Discord Release Notification +description: Sends a release notification to Discord using SethCohen/github-releases-to-discord. + +inputs: + webhook-url: + description: Discord webhook URL + required: true + color: + description: Embed color (decimal) + required: false + default: "2105893" + username: + description: Bot username displayed in Discord + required: false + default: "Release Changelog" + content: + description: Message content (e.g. role mentions) + required: false + default: "" + footer-timestamp: + description: Show timestamp in embed footer + required: false + default: "true" + release-tag: + description: Release tag (e.g. v1.2.3 or v1.0.0-beta.1) + required: true + skip-beta: + description: Skip notification for beta releases + required: false + default: "true" + dry-run: + description: Preview changes without sending the notification + required: false + default: "false" + +runs: + using: composite + steps: + # ----------------- Pre-flight Check ----------------- + - name: Detect beta release + id: beta + shell: bash + run: | + if [[ "${{ inputs.release-tag }}" == *"-beta."* ]]; then + echo "is_beta=true" >> $GITHUB_OUTPUT + else + echo "is_beta=false" >> $GITHUB_OUTPUT + fi + + # ----------------- Dry Run ----------------- + - name: Dry run summary + if: inputs.dry-run == 'true' + shell: bash + run: | + echo "::notice::DRY RUN — Discord notification will not be sent" + echo " webhook : (configured)" + echo " release-tag : ${{ inputs.release-tag }}" + echo " color : ${{ inputs.color }}" + echo " username : ${{ inputs.username }}" + echo " content : ${{ inputs.content }}" + echo " skip-beta : ${{ inputs.skip-beta }}" + echo " is_beta : ${{ steps.beta.outputs.is_beta }}" + + # ----------------- Notification ----------------- + - name: Send Discord notification + if: >- + inputs.dry-run != 'true' + && !(inputs.skip-beta == 'true' && steps.beta.outputs.is_beta == 'true') + uses: SethCohen/github-releases-to-discord@v1.16.2 + with: + webhook_url: ${{ inputs.webhook-url }} + color: ${{ inputs.color }} + username: ${{ inputs.username }} + content: ${{ inputs.content }} + footer_timestamp: ${{ inputs.footer-timestamp }} + + - name: Skipped (beta release) + if: >- + inputs.dry-run != 'true' + && inputs.skip-beta == 'true' + && steps.beta.outputs.is_beta == 'true' + shell: bash + run: echo "::notice::Skipped Discord notification — beta release detected" diff --git a/src/notify/pr-lint-reporter/README.md b/src/notify/pr-lint-reporter/README.md new file mode 100644 index 00000000..25d8fc35 --- /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 00000000..afd81104 --- /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 }); + } diff --git a/src/notify/slack-notify/README.md b/src/notify/slack-notify/README.md new file mode 100644 index 00000000..da4a2dcb --- /dev/null +++ b/src/notify/slack-notify/README.md @@ -0,0 +1,58 @@ + + + + + +
Lerian

slack-notify

+ +Composite action that sends a workflow status notification to Slack with rich formatting. Includes repo name, workflow name, failed jobs, author, branch, commit, and a link to the workflow run. Gracefully skips if the webhook URL is empty. + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `webhook-url` | Slack webhook URL for notifications | Yes | — | +| `status` | Workflow status (`success`, `failure`, `cancelled`) | Yes | — | +| `workflow-name` | Name of the calling workflow | Yes | — | +| `failed-jobs` | Comma-separated list of failed job names | No | `''` | +| `custom-message` | Optional custom message to include | No | `''` | + +## Usage + +### As a composite step (notification job) + +```yaml +jobs: + build: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Build + run: make build + + notify: + needs: build + if: always() + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Slack Notification + uses: LerianStudio/github-actions-shared-workflows/src/notify/slack-notify@develop + with: + webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + status: ${{ needs.build.result }} + workflow-name: 'Build' + failed-jobs: ${{ needs.build.result == 'failure' && 'Build' || '' }} +``` + +### Production usage + +```yaml +- uses: LerianStudio/github-actions-shared-workflows/src/notify/slack-notify@v1.0.0 + with: + webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + status: ${{ needs.build.result }} + workflow-name: 'My Workflow' +``` + +## Permissions required + +No special permissions required — notification is sent via webhook. diff --git a/src/notify/slack-notify/action.yml b/src/notify/slack-notify/action.yml new file mode 100644 index 00000000..d265944e --- /dev/null +++ b/src/notify/slack-notify/action.yml @@ -0,0 +1,129 @@ +name: Slack Notification +description: Sends a workflow status notification to Slack with rich formatting including repo, workflow, failed jobs, and author info. + +inputs: + webhook-url: + description: 'Slack webhook URL for notifications' + required: true + status: + description: 'Workflow status (success, failure, cancelled)' + required: true + workflow-name: + description: 'Name of the calling workflow' + required: true + failed-jobs: + description: 'Comma-separated list of failed job names (for failure notifications)' + required: false + default: '' + custom-message: + description: 'Optional custom message to include' + required: false + default: '' + +runs: + using: composite + steps: + # ----------------- Webhook Check ----------------- + - name: Check if Slack webhook is configured + id: check-webhook + shell: bash + run: | + if [ -z "${{ inputs.webhook-url }}" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "::notice::SLACK_WEBHOOK_URL not configured — skipping notification" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + # ----------------- Build Notification ----------------- + - name: Determine notification settings + if: steps.check-webhook.outputs.skip != 'true' + id: settings + shell: bash + run: | + STATUS="${{ inputs.status }}" + + case "$STATUS" in + success) + COLOR="good" + EMOJI="✅" + STATUS_TEXT="succeeded" + ;; + failure) + COLOR="danger" + EMOJI="❌" + STATUS_TEXT="failed" + ;; + cancelled) + COLOR="#808080" + EMOJI="⚪" + STATUS_TEXT="was cancelled" + ;; + *) + COLOR="warning" + EMOJI="⚠️" + STATUS_TEXT="completed with status: $STATUS" + ;; + esac + + echo "color=$COLOR" >> $GITHUB_OUTPUT + echo "emoji=$EMOJI" >> $GITHUB_OUTPUT + echo "status_text=$STATUS_TEXT" >> $GITHUB_OUTPUT + + - name: Build notification message + if: steps.check-webhook.outputs.skip != 'true' + id: message + shell: bash + run: | + REPO="${{ github.repository }}" + REPO_NAME="${REPO##*/}" + WORKFLOW="${{ inputs.workflow-name }}" + STATUS="${{ inputs.status }}" + STATUS_TEXT="${{ steps.settings.outputs.status_text }}" + EMOJI="${{ steps.settings.outputs.emoji }}" + ACTOR="${{ github.actor }}" + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + COMMIT_SHA="${{ github.sha }}" + SHORT_SHA="${COMMIT_SHA:0:7}" + + if [ "${{ github.event_name }}" = "pull_request" ]; then + REF="PR #${{ github.event.pull_request.number }}" + BRANCH="${{ github.head_ref }}" + else + REF="${{ github.ref_name }}" + BRANCH="$REF" + fi + + MESSAGE="$EMOJI *$WORKFLOW* $STATUS_TEXT in *$REPO_NAME*" + + FAILED_JOBS="${{ inputs.failed-jobs }}" + if [ "$STATUS" = "failure" ] && [ -n "$FAILED_JOBS" ]; then + MESSAGE="$MESSAGE\n💥 *Failed jobs:* $FAILED_JOBS" + fi + + MESSAGE="$MESSAGE\n👤 *Author:* $ACTOR" + MESSAGE="$MESSAGE\n📌 *Branch:* \`$BRANCH\` | *Commit:* \`$SHORT_SHA\`" + + if [ -n "${{ inputs.custom-message }}" ]; then + MESSAGE="$MESSAGE\n\n${{ inputs.custom-message }}" + fi + + MESSAGE="$MESSAGE\n\n<$RUN_URL|🔗 View Workflow Run>" + + echo "message<> $GITHUB_OUTPUT + echo -e "$MESSAGE" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # ----------------- Send Notification ----------------- + - name: Send Slack notification + if: steps.check-webhook.outputs.skip != 'true' + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ inputs.webhook-url }} + SLACK_COLOR: ${{ steps.settings.outputs.color }} + SLACK_USERNAME: GitHub Actions + SLACK_ICON: https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png + SLACK_TITLE: ${{ github.repository }} + SLACK_MESSAGE: ${{ steps.message.outputs.message }} + SLACK_FOOTER: "Workflow: ${{ inputs.workflow-name }}" + MSG_MINIMAL: true diff --git a/src/notify/slack-release/README.md b/src/notify/slack-release/README.md new file mode 100644 index 00000000..b50faa1a --- /dev/null +++ b/src/notify/slack-release/README.md @@ -0,0 +1,68 @@ + + + + + +
Lerian

slack-release

+ +Composite action that sends a release notification to a Slack channel via webhook. Wraps [rtCamp/action-slack-notify](https://github.com/rtCamp/action-slack-notify). + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `webhook-url` | Slack webhook URL | Yes | — | +| `channel` | Slack channel name | Yes | — | +| `product-name` | Product name displayed in the notification title | Yes | — | +| `release-tag` | Release tag (e.g. `v1.2.3`) | Yes | — | +| `color` | Sidebar color for the Slack message | No | `#36a64f` | +| `icon-emoji` | Emoji icon for the bot | No | `:rocket:` | +| `dry-run` | Preview changes without sending the notification | No | `false` | + +## Usage + +### As a composite action (inline step) + +```yaml +jobs: + notify: + runs-on: ubuntu-latest + steps: + - uses: LerianStudio/github-actions-shared-workflows/src/notify/slack-release@v1.2.3 + with: + webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + channel: "releases" + product-name: "MyProduct" + release-tag: "v1.0.0" +``` + +### As a reusable workflow (recommended) + +```yaml +jobs: + notify: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/release-notification.yml@v1.2.3 + with: + product_name: "MyProduct" + slack_channel: "releases" + secrets: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} +``` + +### Dry run (preview only) + +```yaml +- uses: LerianStudio/github-actions-shared-workflows/src/notify/slack-release@develop + with: + webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + channel: "releases" + product-name: "MyProduct" + release-tag: "v1.0.0" + dry-run: "true" +``` + +## Permissions required + +No special permissions required beyond the webhook URL secret. diff --git a/src/notify/slack-release/action.yml b/src/notify/slack-release/action.yml new file mode 100644 index 00000000..5cade456 --- /dev/null +++ b/src/notify/slack-release/action.yml @@ -0,0 +1,55 @@ +name: Slack Release Notification +description: Sends a release notification to Slack using rtCamp/action-slack-notify. + +inputs: + webhook-url: + description: Slack webhook URL + required: true + channel: + description: Slack channel name + required: true + product-name: + description: Product name displayed in the notification title + required: true + release-tag: + description: Release tag (e.g. v1.2.3) + required: true + color: + description: Sidebar color for the Slack message + required: false + default: "#36a64f" + icon-emoji: + description: Emoji icon for the bot + required: false + default: ":rocket:" + dry-run: + description: Preview changes without sending the notification + required: false + default: "false" + +runs: + using: composite + steps: + # ----------------- Dry Run ----------------- + - name: Dry run summary + if: inputs.dry-run == 'true' + shell: bash + run: | + echo "::notice::DRY RUN — Slack notification will not be sent" + echo " channel : ${{ inputs.channel }}" + echo " product-name : ${{ inputs.product-name }}" + echo " release-tag : ${{ inputs.release-tag }}" + echo " color : ${{ inputs.color }}" + echo " icon-emoji : ${{ inputs.icon-emoji }}" + + # ----------------- Notification ----------------- + - name: Send Slack notification + if: inputs.dry-run != 'true' + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_CHANNEL: ${{ inputs.channel }} + SLACK_COLOR: ${{ inputs.color }} + SLACK_ICON_EMOJI: ${{ inputs.icon-emoji }} + SLACK_TITLE: "${{ inputs.product-name }} New Release: ${{ inputs.release-tag }}" + SLACK_MESSAGE: "🎉 *New Release Published!* \n \n " + SLACK_WEBHOOK: ${{ inputs.webhook-url }} diff --git a/src/security/dockerfile-checks/README.md b/src/security/dockerfile-checks/README.md new file mode 100644 index 00000000..283054e4 --- /dev/null +++ b/src/security/dockerfile-checks/README.md @@ -0,0 +1,67 @@ + + + + + +
Lerian

dockerfile-checks

+ +Composite action that runs Docker Hub Health Score compliance checks on a Dockerfile. Verifies non-root user configuration and downloads the CISA Known Exploited Vulnerabilities (KEV) catalog for cross-referencing by [`pr-security-reporter`](../pr-security-reporter/). + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `dockerfile-path` | Path to the Dockerfile to check | Yes | — | + +## Outputs + +| Output | Description | +|---|---| +| `has-non-root-user` | `true` if the Dockerfile sets a non-root `USER` directive, `false` otherwise | +| `cisa-kev-path` | File path of the downloaded CISA KEV catalog | + +## Artifact naming convention + +This composite produces the following file in the runner working directory: + +| File | Format | Purpose | +|---|---|---| +| `cisa-kev.json` | JSON | CISA KEV catalog for cross-referencing CVEs in `pr-security-reporter` | + +## Usage + +### As a composite step (within a security workflow job) + +```yaml +jobs: + security: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v6 + + - name: Dockerfile Compliance Checks + id: dockerfile-checks + uses: LerianStudio/github-actions-shared-workflows/src/security/dockerfile-checks@develop + with: + dockerfile-path: './Dockerfile' + + - name: Use results + run: | + echo "Non-root user: ${{ steps.dockerfile-checks.outputs.has-non-root-user }}" + echo "KEV catalog: ${{ steps.dockerfile-checks.outputs.cisa-kev-path }}" +``` + +### Production usage + +```yaml +- uses: LerianStudio/github-actions-shared-workflows/src/security/dockerfile-checks@v1.0.0 + with: + dockerfile-path: './Dockerfile' +``` + +## Permissions required + +```yaml +permissions: + contents: read +``` diff --git a/src/security/dockerfile-checks/action.yml b/src/security/dockerfile-checks/action.yml new file mode 100644 index 00000000..f9fe4628 --- /dev/null +++ b/src/security/dockerfile-checks/action.yml @@ -0,0 +1,43 @@ +name: Dockerfile Compliance Checks +description: Runs Docker Hub Health Score compliance checks — non-root user verification and CISA KEV catalog download. + +inputs: + dockerfile-path: + description: 'Path to the Dockerfile to check (e.g., "./Dockerfile" or "components/api/Dockerfile")' + required: true + +outputs: + has-non-root-user: + description: 'Whether the Dockerfile sets a non-root USER directive (true/false)' + value: ${{ steps.check-user.outputs.has_non_root_user }} + cisa-kev-path: + description: 'File path of the downloaded CISA KEV catalog' + value: ${{ steps.set-outputs.outputs.cisa-kev-path }} + +runs: + using: composite + steps: + # ----------------- CISA KEV Catalog ----------------- + - name: Download CISA Known Exploited Vulnerabilities catalog + shell: bash + run: | + curl -sSfL -o cisa-kev.json \ + https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json || echo '{"vulnerabilities":[]}' > cisa-kev.json + + # ----------------- Dockerfile Checks ----------------- + - name: Check Dockerfile for non-root user + id: check-user + shell: bash + run: | + LAST_USER=$(grep -Ei '^\s*USER\s+\S+' "${{ inputs.dockerfile-path }}" | tail -1 | awk '{print $2}' || echo "") + if [ -n "$LAST_USER" ] && [ "$LAST_USER" != "root" ] && [ "$LAST_USER" != "0" ]; then + echo "has_non_root_user=true" >> "$GITHUB_OUTPUT" + else + echo "has_non_root_user=false" >> "$GITHUB_OUTPUT" + fi + + # ----------------- Outputs ----------------- + - name: Set output paths + id: set-outputs + shell: bash + run: echo "cisa-kev-path=cisa-kev.json" >> "$GITHUB_OUTPUT" diff --git a/src/security/pr-security-reporter/README.md b/src/security/pr-security-reporter/README.md new file mode 100644 index 00000000..197fe89f --- /dev/null +++ b/src/security/pr-security-reporter/README.md @@ -0,0 +1,99 @@ + + + + + +
Lerian

pr-security-reporter

+ +Composite action that posts a formatted security scan summary as a PR comment, combining Trivy filesystem scan, Docker image scan, and Docker Hub Health Score compliance checks. Updates the comment on subsequent runs instead of creating duplicates. + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `github-token` | GitHub token with `pull-requests:write` and `issues:write` | Yes | — | +| `app-name` | Application name — used to locate scan artifacts and scope the PR comment | Yes | — | +| `enable-docker-scan` | Whether Docker image scan artifacts are present and should be included | No | `true` | +| `enable-health-score` | Whether Docker Hub Health Score compliance checks should be included | No | `false` | +| `dockerfile-has-non-root-user` | Whether the Dockerfile sets a non-root USER directive | No | `false` | +| `fail-on-findings` | Fail the step with exit code 1 when security findings are detected | No | `false` | + +## Outputs + +| Output | Description | +|---|---| +| `has-findings` | `true` if any security vulnerabilities were found | +| `has-errors` | `true` if any scan artifacts were missing or could not be parsed | + +## Artifact naming convention + +This composite expects the following files in the runner working directory, generated by upstream Trivy steps: + +| File | Source step | +|---|---| +| `trivy-fs-vuln-.json` | Trivy filesystem scan (JSON format) | +| `trivy-vulnerability-scan-docker-.sarif` | Trivy Docker image scan (SARIF format) | +| `trivy-license-scan-docker-.json` | Trivy license scan (JSON format, for health score) | + +## Usage + +### As a composite step (within a security workflow job) + +```yaml +- name: Post Security Scan Results to PR + id: post-results + if: always() && github.event_name == 'pull_request' + uses: LerianStudio/github-actions-shared-workflows/src/security/pr-security-reporter@develop + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + app-name: ${{ env.APP_NAME }} + enable-docker-scan: ${{ inputs.enable_docker_scan }} + enable-health-score: ${{ inputs.enable_health_score }} + dockerfile-has-non-root-user: ${{ steps.dockerfile-checks.outputs.has-non-root-user }} +``` + +### Production usage + +```yaml +- uses: LerianStudio/github-actions-shared-workflows/src/security/pr-security-reporter@v1.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + app-name: my-service +``` + +### With built-in gate (fail on findings) + +```yaml +- uses: LerianStudio/github-actions-shared-workflows/src/security/pr-security-reporter@v1.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + app-name: my-service + fail-on-findings: 'true' +``` + +### Gate via outputs (manual control) + +```yaml +- name: Post Security Scan Results to PR + id: post-results + uses: LerianStudio/github-actions-shared-workflows/src/security/pr-security-reporter@v1.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + app-name: my-service + +- name: Custom gate logic + if: always() + run: | + if [ "${{ steps.post-results.outputs.has-findings }}" = "true" ]; then + echo "::error::Security vulnerabilities found." + exit 1 + fi +``` + +## Permissions required + +```yaml +permissions: + pull-requests: write + issues: write +``` diff --git a/src/security/pr-security-reporter/action.yml b/src/security/pr-security-reporter/action.yml new file mode 100644 index 00000000..4ee3ecc7 --- /dev/null +++ b/src/security/pr-security-reporter/action.yml @@ -0,0 +1,345 @@ +name: PR Security Reporter +description: Posts a formatted security scan summary comment on the pull request, updating it on subsequent runs. + +inputs: + github-token: + description: GitHub token with pull-requests:write and issues:write permissions + required: true + app-name: + description: Application name used to locate scan artifact files and scope the PR comment + required: true + enable-docker-scan: + description: Whether Docker image scan artifacts are present and should be included in the report + required: false + default: "true" + enable-health-score: + description: Whether Docker Hub Health Score compliance checks should be included in the report + required: false + default: "false" + dockerfile-has-non-root-user: + description: Whether the Dockerfile sets a non-root USER directive + required: false + default: "false" + fail-on-findings: + description: 'Fail the step with exit code 1 when security findings are detected (true/false)' + required: false + default: "false" + +outputs: + has-findings: + description: True if any security vulnerabilities were found + value: ${{ steps.parse-outputs.outputs.has_findings }} + has-errors: + description: True if any scan artifacts were missing or could not be parsed + value: ${{ steps.parse-outputs.outputs.has_errors }} + +runs: + using: composite + steps: + - name: Post security report to PR + id: report + uses: actions/github-script@v8 + env: + APP_NAME: ${{ inputs.app-name }} + ENABLE_DOCKER_SCAN: ${{ inputs.enable-docker-scan }} + ENABLE_HEALTH_SCORE: ${{ inputs.enable-health-score }} + DOCKERFILE_HAS_NON_ROOT_USER: ${{ inputs.dockerfile-has-non-root-user }} + with: + github-token: ${{ inputs.github-token }} + result-encoding: string + script: | + const fs = require('fs'); + + // ── Configuration ── + const appName = process.env.APP_NAME; + const dockerScanEnabled = process.env.ENABLE_DOCKER_SCAN === 'true'; + const healthScoreEnabled = process.env.ENABLE_HEALTH_SCORE === 'true'; + const dockerfileHasNonRootUser = process.env.DOCKERFILE_HAS_NON_ROOT_USER === 'true'; + + let body = ''; + let hasFindings = false; + let hasScanErrors = false; + + // ── Helpers ── + const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, UNKNOWN: 4 }; + const SEVERITY_ICONS = { CRITICAL: '\u{1F534}', HIGH: '\u{1F7E0}', MEDIUM: '\u{1F7E1}', LOW: '\u{1F535}', UNKNOWN: '\u26AA' }; + + const md = (value) => + String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').replace(/`/g, '\\`'); + + const truncate = (str, max) => + str.length > max ? str.substring(0, max - 3) + '...' : str; + + const sortBySeverity = (items, key = 'severity', order = SEVERITY_ORDER) => + items.sort((a, b) => (order[a[key]] ?? 5) - (order[b[key]] ?? 5)); + + // ── Trivy: Filesystem Scan ── + function buildTrivyFsScan() { + try { + const file = `trivy-fs-vuln-${appName}.json`; + if (!fs.existsSync(file)) { + hasScanErrors = true; + return `#### Filesystem Scan\n\n\u26A0\uFE0F Scan artifact not found.\n\n`; + } + + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + const vulns = []; + + for (const result of (data.Results || [])) { + for (const v of (result.Vulnerabilities || [])) { + vulns.push({ + library: v.PkgName || 'Unknown', + id: v.VulnerabilityID || 'N/A', + severity: v.Severity || 'UNKNOWN', + installed: v.InstalledVersion || 'N/A', + fixed: v.FixedVersion || 'N/A', + title: v.Title || v.Description || 'No description', + }); + } + for (const s of (result.Secrets || [])) { + vulns.push({ + library: s.RuleID || 'Secret', + id: s.Category || 'SECRET', + severity: s.Severity || 'HIGH', + installed: '[REDACTED]', + fixed: 'Remove/rotate', + title: s.Title || 'Secret detected', + }); + } + } + + if (vulns.length === 0) { + return `#### Filesystem Scan\n\n\u2705 No vulnerabilities or secrets found.\n\n`; + } + + hasFindings = true; + sortBySeverity(vulns); + + const MAX = 50; + let out = `#### Filesystem Scan\n\n`; + out += `| Severity | Library | Vulnerability | Installed | Fixed | Title |\n`; + out += `|----------|---------|---------------|-----------|-------|-------|\n`; + + for (const v of vulns.slice(0, MAX)) { + const icon = SEVERITY_ICONS[v.severity] || '\u26AA'; + out += `| ${icon} ${md(v.severity)} | \`${md(v.library)}\` | ${md(v.id)} | ${md(v.installed)} | ${md(v.fixed)} | ${md(truncate(v.title, 60))} |\n`; + } + + if (vulns.length > MAX) out += `\n_... and ${vulns.length - MAX} more findings._\n`; + return out + `\n`; + } catch (e) { + hasScanErrors = true; + return `#### Filesystem Scan\n\n\u26A0\uFE0F Could not parse scan results: ${e.message}\n\n`; + } + } + + // ── Trivy: Docker Image Scan ── + function buildTrivyDockerScan() { + if (!dockerScanEnabled) return ''; + + try { + const file = `trivy-vulnerability-scan-docker-${appName}.sarif`; + if (!fs.existsSync(file)) { + hasScanErrors = true; + return `#### Docker Image Scan\n\n\u26A0\uFE0F Scan artifact not found.\n\n`; + } + + const sarif = JSON.parse(fs.readFileSync(file, 'utf8')); + const vulns = []; + const severityMap = { error: 'CRITICAL/HIGH', warning: 'MEDIUM' }; + + for (const run of (sarif.runs || [])) { + for (const result of (run.results || [])) { + const rule = (run.tool?.driver?.rules || []).find(r => r.id === result.ruleId); + vulns.push({ + id: result.ruleId || 'N/A', + severity: severityMap[result.level] || 'LOW', + title: rule?.shortDescription?.text || result.message?.text || 'No description', + }); + } + } + + if (vulns.length === 0) { + return `#### Docker Image Scan\n\n\u2705 No vulnerabilities found.\n\n`; + } + + hasFindings = true; + const dockerOrder = { 'CRITICAL/HIGH': 0, CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, UNKNOWN: 4 }; + sortBySeverity(vulns, 'severity', dockerOrder); + + const MAX = 20; + let out = `#### Docker Image Scan\n\n`; + out += `| Severity | Vulnerability | Title |\n`; + out += `|----------|---------------|-------|\n`; + + for (const v of vulns.slice(0, MAX)) { + out += `| ${md(v.severity)} | ${md(v.id)} | ${md(truncate(v.title, 80))} |\n`; + } + + if (vulns.length > MAX) out += `\n_... and ${vulns.length - MAX} more findings._\n`; + return out + `\n`; + } catch (e) { + hasScanErrors = true; + return `#### Docker Image Scan\n\n\u26A0\uFE0F Could not parse scan results: ${e.message}\n\n`; + } + } + + // ── Docker Hub Health Score Compliance ── + function buildHealthScoreSection() { + if (!healthScoreEnabled || !dockerScanEnabled) return ''; + + const policies = []; + let passCount = 0; + + // Policy 1: Default non-root user + const nonRootPassed = dockerfileHasNonRootUser; + policies.push({ name: 'Default non-root user', status: nonRootPassed ? '\u2705 Passed' : '\u26A0\uFE0F Failed', passed: nonRootPassed }); + if (nonRootPassed) passCount++; + + // Policy 2: No fixable critical or high vulnerabilities (from Trivy Docker SARIF) + let fixableCvesPassed = true; + try { + const sarifFile = `trivy-vulnerability-scan-docker-${appName}.sarif`; + if (fs.existsSync(sarifFile)) { + const sarif = JSON.parse(fs.readFileSync(sarifFile, 'utf8')); + let critHighCount = 0; + for (const run of (sarif.runs || [])) { + for (const result of (run.results || [])) { + if (result.level === 'error') critHighCount++; + } + } + fixableCvesPassed = critHighCount === 0; + } + } catch {} + policies.push({ name: 'No fixable critical/high CVEs', status: fixableCvesPassed ? '\u2705 Passed' : '\u26A0\uFE0F Failed', passed: fixableCvesPassed }); + if (fixableCvesPassed) passCount++; + + // Policy 3: No high-profile vulnerabilities (cross-reference Trivy CVEs with CISA KEV catalog) + let kevPassed = true; + try { + const kevFile = 'cisa-kev.json'; + const sarifFile = `trivy-vulnerability-scan-docker-${appName}.sarif`; + if (fs.existsSync(kevFile) && fs.existsSync(sarifFile)) { + const kev = JSON.parse(fs.readFileSync(kevFile, 'utf8')); + const kevIds = new Set((kev.vulnerabilities || []).map(v => v.cveID)); + const sarif = JSON.parse(fs.readFileSync(sarifFile, 'utf8')); + for (const run of (sarif.runs || [])) { + for (const result of (run.results || [])) { + if (kevIds.has(result.ruleId)) { + kevPassed = false; + break; + } + } + if (!kevPassed) break; + } + } + } catch {} + policies.push({ name: 'No high-profile vulnerabilities', status: kevPassed ? '\u2705 Passed' : '\u26A0\uFE0F Failed', passed: kevPassed }); + if (kevPassed) passCount++; + + // Policy 4: No AGPL v3 licenses (from Trivy license scan) + let licensePassed = true; + try { + const licFile = `trivy-license-scan-docker-${appName}.json`; + if (fs.existsSync(licFile)) { + const data = JSON.parse(fs.readFileSync(licFile, 'utf8')); + for (const result of (data.Results || [])) { + for (const lic of (result.Licenses || [])) { + if (/AGPL-3/i.test(lic.Name) || /AGPL-3/i.test(lic.Category)) { + licensePassed = false; + break; + } + } + if (!licensePassed) break; + } + } + } catch {} + policies.push({ name: 'No AGPL v3 licenses', status: licensePassed ? '\u2705 Passed' : '\u26A0\uFE0F Failed', passed: licensePassed }); + if (licensePassed) passCount++; + + const totalPolicies = policies.length; + const failCount = totalPolicies - passCount; + const icon = failCount > 0 ? '\u26A0\uFE0F' : '\u2705'; + + let out = `---\n\n## Docker Hub Health Score Compliance\n\n`; + out += `#### ${icon} Policies \u2014 ${passCount}/${totalPolicies} met\n\n`; + out += `| Policy | Status |\n|--------|--------|\n`; + for (const p of policies) { + out += `| ${p.name} | ${p.status} |\n`; + } + out += '\n'; + + if (failCount > 0) { + hasFindings = true; + out += `> \u26A0\uFE0F **Some Docker Hub health score policies are not met. Review the table above.**\n\n`; + } + + return out; + } + + // ── Build Report ── + body += `## \u{1F512} Security Scan Results \u2014 \`${appName}\`\n\n`; + body += `## Trivy\n\n`; + body += buildTrivyFsScan(); + body += buildTrivyDockerScan(); + body += buildHealthScoreSection(); + + // ── Useful Links ── + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + body += `---\n\n`; + body += `\u{1F50D} [View full scan logs](${runUrl})\n\n`; + + // ── Post Comment ── + const marker = ``; + body = marker + '\n' + body; + + try { + 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 }); + } + } catch (e) { + core.warning(`Could not post PR security comment: ${e.message}`); + } + + // ── Outputs (return JSON for next step to parse) ── + return JSON.stringify({ has_findings: hasFindings, has_errors: hasScanErrors }); + + - name: Parse outputs + id: parse-outputs + shell: bash + run: | + RESULT='${{ steps.report.outputs.result }}' + HAS_FINDINGS=$(echo "$RESULT" | jq -r '.has_findings // false') + HAS_ERRORS=$(echo "$RESULT" | jq -r '.has_errors // false') + echo "has_findings=$HAS_FINDINGS" >> "$GITHUB_OUTPUT" + echo "has_errors=$HAS_ERRORS" >> "$GITHUB_OUTPUT" + echo "Parsed outputs: has_findings=$HAS_FINDINGS, has_errors=$HAS_ERRORS" + + # ----------------- Security Gate ----------------- + - name: Gate - Fail on Security Findings + if: inputs.fail-on-findings == 'true' + shell: bash + run: | + if [ "${{ steps.parse-outputs.outputs.has_errors }}" = "true" ]; then + echo "::warning::Some scan artifacts were missing or could not be parsed." + fi + if [ "${{ steps.parse-outputs.outputs.has_findings }}" = "true" ]; then + echo "::error::Security vulnerabilities found. Check the PR comment for details." + exit 1 + fi diff --git a/src/security/trivy-fs-scan/README.md b/src/security/trivy-fs-scan/README.md new file mode 100644 index 00000000..1a223366 --- /dev/null +++ b/src/security/trivy-fs-scan/README.md @@ -0,0 +1,79 @@ + + + + + +
Lerian

trivy-fs-scan

+ +Composite action that runs Trivy filesystem scans for secrets and vulnerabilities. Produces human-readable table output (with configurable fail behavior) and machine-readable artifacts (SARIF and JSON) for downstream consumption by [`pr-security-reporter`](../pr-security-reporter/). + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `scan-ref` | Path to scan (e.g., `.` for repo root or a component directory) | No | `.` | +| `app-name` | Application name — used for artifact file naming | Yes | — | +| `skip-dirs` | Comma-separated directories to skip during scanning | No | `.git,node_modules,dist,build,.next,coverage,vendor` | +| `trivy-version` | Trivy version to install | No | `v0.69.3` | +| `exit-code-secret-scan` | Exit code when secrets are found in table output (`1` to fail, `0` to warn only) | No | `1` | + +## Outputs + +| Output | Description | +|---|---| +| `secret-scan-sarif` | File path of the secret scan SARIF artifact | +| `fs-vuln-json` | File path of the filesystem vulnerability JSON artifact | + +## Artifact naming convention + +This composite produces the following files in the runner working directory: + +| File | Format | Purpose | +|---|---|---| +| `trivy-secret-scan-.sarif` | SARIF | Secret scan results for GitHub Security tab upload | +| `trivy-fs-vuln-.json` | JSON | Filesystem vulnerability results for `pr-security-reporter` | + +## Usage + +### As a composite step (within a security workflow job) + +```yaml +jobs: + security: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v6 + + - name: Trivy Filesystem Scan + id: fs-scan + uses: LerianStudio/github-actions-shared-workflows/src/security/trivy-fs-scan@develop + with: + scan-ref: '.' + app-name: 'my-service' +``` + +### Monorepo usage (scan a specific component) + +```yaml +- name: Trivy Filesystem Scan + uses: LerianStudio/github-actions-shared-workflows/src/security/trivy-fs-scan@develop + with: + scan-ref: ${{ matrix.working_dir }} + app-name: ${{ matrix.name }} +``` + +### Production usage + +```yaml +- uses: LerianStudio/github-actions-shared-workflows/src/security/trivy-fs-scan@v1.0.0 + with: + app-name: my-service +``` + +## Permissions required + +```yaml +permissions: + contents: read + security-events: write # only if uploading SARIF to GitHub Security tab +``` diff --git a/src/security/trivy-fs-scan/action.yml b/src/security/trivy-fs-scan/action.yml new file mode 100644 index 00000000..52b190ff --- /dev/null +++ b/src/security/trivy-fs-scan/action.yml @@ -0,0 +1,85 @@ +name: Trivy Filesystem Scan +description: Runs Trivy filesystem scans for secrets and vulnerabilities, producing table output and machine-readable artifacts. + +inputs: + scan-ref: + description: 'Path to scan (e.g., "." for repo root or a component directory)' + required: false + default: '.' + app-name: + description: 'Application name used for artifact file naming' + required: true + skip-dirs: + description: 'Comma-separated directories to skip during scanning' + required: false + default: '.git,node_modules,dist,build,.next,coverage,vendor' + trivy-version: + description: 'Trivy version to install' + required: false + default: 'v0.69.3' + exit-code-secret-scan: + description: 'Exit code when secrets are found in table output (1 to fail, 0 to warn only)' + required: false + default: '1' + +outputs: + secret-scan-sarif: + description: 'File path of the secret scan SARIF artifact' + value: ${{ steps.set-outputs.outputs.secret-scan-sarif }} + fs-vuln-json: + description: 'File path of the filesystem vulnerability JSON artifact' + value: ${{ steps.set-outputs.outputs.fs-vuln-json }} + +runs: + using: composite + steps: + # ----------------- Secret Scanning ----------------- + - name: Trivy Secret Scan (Table Output) + uses: aquasecurity/trivy-action@0.35.0 + with: + scan-type: fs + scanners: secret + scan-ref: ${{ inputs.scan-ref }} + format: table + exit-code: ${{ inputs.exit-code-secret-scan }} + hide-progress: true + skip-dirs: ${{ inputs.skip-dirs }} + version: ${{ inputs.trivy-version }} + + - name: Trivy Secret Scan (SARIF Output) + uses: aquasecurity/trivy-action@0.35.0 + if: always() + with: + scan-type: fs + scanners: secret + scan-ref: ${{ inputs.scan-ref }} + format: sarif + output: 'trivy-secret-scan-${{ inputs.app-name }}.sarif' + exit-code: '0' + hide-progress: true + skip-dirs: ${{ inputs.skip-dirs }} + version: ${{ inputs.trivy-version }} + + # ----------------- Vulnerability Scanning ----------------- + - name: Trivy Vulnerability Scan - Filesystem (JSON Output) + uses: aquasecurity/trivy-action@0.35.0 + if: always() + with: + scan-type: fs + scanners: vuln + scan-ref: ${{ inputs.scan-ref }} + format: json + output: 'trivy-fs-vuln-${{ inputs.app-name }}.json' + exit-code: '0' + hide-progress: true + skip-dirs: ${{ inputs.skip-dirs }} + version: ${{ inputs.trivy-version }} + + # ----------------- Outputs ----------------- + - name: Set output paths + id: set-outputs + if: always() + shell: bash + run: | + echo "secret-scan-sarif=trivy-secret-scan-${{ inputs.app-name }}.sarif" >> "$GITHUB_OUTPUT" + echo "fs-vuln-json=trivy-fs-vuln-${{ inputs.app-name }}.json" >> "$GITHUB_OUTPUT" diff --git a/src/security/trivy-image-scan/README.md b/src/security/trivy-image-scan/README.md new file mode 100644 index 00000000..0d1b4a3e --- /dev/null +++ b/src/security/trivy-image-scan/README.md @@ -0,0 +1,81 @@ + + + + + +
Lerian

trivy-image-scan

+ +Composite action that runs Trivy vulnerability and license scans on a Docker image. Produces human-readable table output and machine-readable artifacts (SARIF and JSON) for downstream consumption by [`pr-security-reporter`](../pr-security-reporter/). + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `image-ref` | Docker image reference to scan (e.g., `org/app:tag`) | Yes | — | +| `app-name` | Application name — used for artifact file naming | Yes | — | +| `severity-table` | Severity levels to show in table output | No | `CRITICAL,HIGH` | +| `severity-sarif` | Severity levels to capture in SARIF artifact | No | `UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL` | +| `ignore-unfixed` | Only report vulnerabilities with available fixes | No | `true` | +| `vuln-type` | Vulnerability types to scan for (comma-separated) | No | `os,library` | +| `enable-license-scan` | Run license compliance scan and produce JSON artifact | No | `false` | +| `trivy-version` | Trivy version to install | No | `v0.69.3` | + +## Outputs + +| Output | Description | +|---|---| +| `vuln-scan-sarif` | File path of the vulnerability scan SARIF artifact | +| `license-scan-json` | File path of the license scan JSON artifact (empty if license scan disabled) | + +## Artifact naming convention + +This composite produces the following files in the runner working directory: + +| File | Format | Condition | Purpose | +|---|---|---|---| +| `trivy-vulnerability-scan-docker-.sarif` | SARIF | Always | Vulnerability results for `pr-security-reporter` and GitHub Security tab | +| `trivy-license-scan-docker-.json` | JSON | `enable-license-scan: true` | License results for health score compliance | + +## Usage + +### As a composite step (within a security workflow job) + +```yaml +jobs: + security: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-buildx-action@v4 + + - name: Build Docker Image + uses: docker/build-push-action@v7 + with: + load: true + push: false + tags: myorg/myapp:scan + + - name: Trivy Image Scan + uses: LerianStudio/github-actions-shared-workflows/src/security/trivy-image-scan@develop + with: + image-ref: 'myorg/myapp:scan' + app-name: 'my-service' + enable-license-scan: 'true' +``` + +### Production usage + +```yaml +- uses: LerianStudio/github-actions-shared-workflows/src/security/trivy-image-scan@v1.0.0 + with: + image-ref: 'myorg/myapp:scan' + app-name: my-service +``` + +## Permissions required + +```yaml +permissions: + contents: read + security-events: write # only if uploading SARIF to GitHub Security tab +``` diff --git a/src/security/trivy-image-scan/action.yml b/src/security/trivy-image-scan/action.yml new file mode 100644 index 00000000..63eed4e1 --- /dev/null +++ b/src/security/trivy-image-scan/action.yml @@ -0,0 +1,95 @@ +name: Trivy Image Scan +description: Runs Trivy vulnerability and license scans on a Docker image, producing table output and machine-readable artifacts. + +inputs: + image-ref: + description: 'Docker image reference to scan (e.g., "org/app:tag")' + required: true + app-name: + description: 'Application name used for artifact file naming' + required: true + severity-table: + description: 'Severity levels to show in table output' + required: false + default: 'CRITICAL,HIGH' + severity-sarif: + description: 'Severity levels to capture in SARIF artifact' + required: false + default: 'UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL' + ignore-unfixed: + description: 'Only report vulnerabilities with available fixes' + required: false + default: 'true' + vuln-type: + description: 'Vulnerability types to scan for (comma-separated)' + required: false + default: 'os,library' + enable-license-scan: + description: 'Run license compliance scan and produce JSON artifact' + required: false + default: 'false' + trivy-version: + description: 'Trivy version to install' + required: false + default: 'v0.69.3' + +outputs: + vuln-scan-sarif: + description: 'File path of the vulnerability scan SARIF artifact' + value: ${{ steps.set-outputs.outputs.vuln-scan-sarif }} + license-scan-json: + description: 'File path of the license scan JSON artifact (empty if license scan disabled)' + value: ${{ steps.set-outputs.outputs.license-scan-json }} + +runs: + using: composite + steps: + # ----------------- Vulnerability Scanning ----------------- + - name: Trivy Vulnerability Scan (Table Output) + uses: aquasecurity/trivy-action@0.35.0 + with: + image-ref: ${{ inputs.image-ref }} + format: table + ignore-unfixed: ${{ inputs.ignore-unfixed }} + vuln-type: ${{ inputs.vuln-type }} + severity: ${{ inputs.severity-table }} + exit-code: '0' + version: ${{ inputs.trivy-version }} + + - name: Trivy Vulnerability Scan (SARIF Output) + uses: aquasecurity/trivy-action@0.35.0 + if: always() + with: + image-ref: ${{ inputs.image-ref }} + format: sarif + output: 'trivy-vulnerability-scan-docker-${{ inputs.app-name }}.sarif' + ignore-unfixed: ${{ inputs.ignore-unfixed }} + vuln-type: ${{ inputs.vuln-type }} + severity: ${{ inputs.severity-sarif }} + exit-code: '0' + version: ${{ inputs.trivy-version }} + + # ----------------- License Scanning ----------------- + - name: Trivy License Scan (JSON Output) + uses: aquasecurity/trivy-action@0.35.0 + if: always() && inputs.enable-license-scan == 'true' + with: + image-ref: ${{ inputs.image-ref }} + scanners: 'license' + format: json + output: 'trivy-license-scan-docker-${{ inputs.app-name }}.json' + exit-code: '0' + version: ${{ inputs.trivy-version }} + + # ----------------- Outputs ----------------- + - name: Set output paths + id: set-outputs + if: always() + shell: bash + run: | + echo "vuln-scan-sarif=trivy-vulnerability-scan-docker-${{ inputs.app-name }}.sarif" >> "$GITHUB_OUTPUT" + if [ "${{ inputs.enable-license-scan }}" = "true" ]; then + echo "license-scan-json=trivy-license-scan-docker-${{ inputs.app-name }}.json" >> "$GITHUB_OUTPUT" + else + echo "license-scan-json=" >> "$GITHUB_OUTPUT" + fi