From 263f61918011396a1e9886533387758f61851431 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 6 Mar 2026 15:59:49 -0300 Subject: [PATCH 01/40] chore(ci): fix reference --- .github/workflows/labels-sync.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labels-sync.yml b/.github/workflows/labels-sync.yml index 99b5c3c0..f648ef92 100644 --- a/.github/workflows/labels-sync.yml +++ b/.github/workflows/labels-sync.yml @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Sync labels - uses: ./src/labels-sync + uses: ./src/config/labels-sync with: github-token: ${{ secrets.GITHUB_TOKEN }} config: ${{ inputs.config || '.github/labels.yml' }} From 58b64d0711d1fe5086df5d5d8bd3ba8285af9b36 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 6 Mar 2026 17:52:16 -0300 Subject: [PATCH 02/40] fix(ci): removing secret values --- .github/workflows/branch-cleanup.yml | 3 --- .github/workflows/labels-sync.yml | 3 --- 2 files changed, 6 deletions(-) diff --git a/.github/workflows/branch-cleanup.yml b/.github/workflows/branch-cleanup.yml index 7ef37a79..09340f19 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: diff --git a/.github/workflows/labels-sync.yml b/.github/workflows/labels-sync.yml index f648ef92..dd76d82a 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: From 6d3c7a22d0dcb5dc9dc38ea6763823050c3e2984 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:44:44 +0000 Subject: [PATCH 03/40] chore(deps): bump actions/github-script in the utilities group Bumps the utilities group with 1 update: [actions/github-script](https://github.com/actions/github-script). Updates `actions/github-script` from 7 to 8 - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major dependency-group: utilities ... Signed-off-by: dependabot[bot] --- .github/workflows/frontend-pr-analysis.yml | 2 +- .github/workflows/pr-security-scan.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/frontend-pr-analysis.yml b/.github/workflows/frontend-pr-analysis.yml index aebf2af5..06fe483d 100644 --- a/.github/workflows/frontend-pr-analysis.yml +++ b/.github/workflows/frontend-pr-analysis.yml @@ -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: | diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 4953a8b7..82229b16 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -220,7 +220,7 @@ jobs: - name: Post Security Scan Results to PR id: post-results if: always() && github.event_name == 'pull_request' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const fs = require('fs'); From 08bd664b1b02ae5982aa9ecbab7a03650ce68d9a Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Mon, 9 Mar 2026 14:57:53 -0300 Subject: [PATCH 04/40] feat(build): add typescript docker build workflow and composite --- .github/workflows/typescript-build.yml | 300 +++++++++++++++++++++++++ docs/typescript-build.md | 211 +++++++++++++++++ src/build/docker-build-ts/README.md | 80 +++++++ src/build/docker-build-ts/action.yml | 222 ++++++++++++++++++ 4 files changed, 813 insertions(+) create mode 100644 .github/workflows/typescript-build.yml create mode 100644 docs/typescript-build.md create mode 100644 src/build/docker-build-ts/README.md create mode 100644 src/build/docker-build-ts/action.yml diff --git a/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml new file mode 100644 index 00000000..5031ea01 --- /dev/null +++ b/.github/workflows/typescript-build.yml @@ -0,0 +1,300 @@ +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: '' + 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-changed-paths@main + 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 }} + + - name: Set matrix + id: set-matrix + run: | + COMPONENTS_JSON='${{ inputs.components_json }}' + if [ -n "$COMPONENTS_JSON" ]; then + echo "matrix=$COMPONENTS_JSON" >> $GITHUB_OUTPUT + echo "has_builds=true" >> $GITHUB_OUTPUT + elif [ -z "${{ inputs.filter_paths }}" ]; then + APP_NAME="${{ github.event.repository.name }}" + 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 + else + echo "matrix=$MATRIX" >> $GITHUB_OUTPUT + echo "has_builds=true" >> $GITHUB_OUTPUT + fi + fi + + - name: Set platforms based on tag type + id: set-platforms + run: | + 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 + run: | + TAG="${GITHUB_REF#refs/tags/}" + 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: ./src/build/docker-build-ts + 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: ${{ 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@v6 + 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: ${{ github.ref_name }} + 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/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..d9fbaeb1 --- /dev/null +++ b/src/build/docker-build-ts/action.yml @@ -0,0 +1,222 @@ +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: + - name: Set up QEMU + if: ${{ contains(inputs.platforms, 'arm64') }} + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to DockerHub + if: ${{ inputs.enable-dockerhub == 'true' }} + uses: docker/login-action@v3 + with: + username: ${{ inputs.dockerhub-username }} + password: ${{ inputs.dockerhub-password }} + + - name: Log in to GHCR + if: ${{ inputs.enable-ghcr == 'true' }} + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ inputs.ghcr-token }} + + - 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@v5 + 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: true — verbose summary, build without pushing + - 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@v6 + 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 }} + + # dry_run: false — build and push + - name: Build and push Docker image + if: ${{ inputs.dry-run != 'true' }} + id: build-push + uses: docker/build-push-action@v6 + 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 }} From 064c173987c1612e65c821dcef5f3e44f48ebde8 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Mon, 9 Mar 2026 15:33:23 -0300 Subject: [PATCH 05/40] fix(build): reference composite via external path instead of local --- .github/workflows/typescript-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml index 5031ea01..c30928c8 100644 --- a/.github/workflows/typescript-build.yml +++ b/.github/workflows/typescript-build.yml @@ -218,7 +218,7 @@ jobs: uses: actions/checkout@v6 - name: Build and push Docker image - uses: ./src/build/docker-build-ts + uses: LerianStudio/github-actions-shared-workflows/src/build/docker-build-ts@feat/typescript-build with: enable-dockerhub: ${{ inputs.enable_dockerhub }} enable-ghcr: ${{ inputs.enable_ghcr }} From 43be833c9aa873c18d34ee04ca04949060e78c2a Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Mon, 9 Mar 2026 16:12:42 -0300 Subject: [PATCH 06/40] fix(build): address CodeRabbit review feedback on typescript build workflow --- .github/workflows/typescript-build.yml | 9 ++++++--- src/build/docker-build-ts/action.yml | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml index c30928c8..728c2226 100644 --- a/.github/workflows/typescript-build.yml +++ b/.github/workflows/typescript-build.yml @@ -164,8 +164,9 @@ jobs: - name: Set matrix id: set-matrix + env: + COMPONENTS_JSON: ${{ inputs.components_json }} run: | - COMPONENTS_JSON='${{ inputs.components_json }}' if [ -n "$COMPONENTS_JSON" ]; then echo "matrix=$COMPONENTS_JSON" >> $GITHUB_OUTPUT echo "has_builds=true" >> $GITHUB_OUTPUT @@ -200,6 +201,8 @@ jobs: id: set-version run: | TAG="${GITHUB_REF#refs/tags/}" + # 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 VERSION=$(echo "$TAG" | sed 's/.*-\(v[0-9]\)/\1/') echo "version=$VERSION" >> $GITHUB_OUTPUT @@ -218,7 +221,7 @@ jobs: uses: actions/checkout@v6 - name: Build and push Docker image - uses: LerianStudio/github-actions-shared-workflows/src/build/docker-build-ts@feat/typescript-build + uses: LerianStudio/github-actions-shared-workflows/src/build/docker-build-ts@develop with: enable-dockerhub: ${{ inputs.enable_dockerhub }} enable-ghcr: ${{ inputs.enable_ghcr }} @@ -251,7 +254,7 @@ jobs: - name: Upload GitOps tag artifact if: inputs.enable_gitops_artifacts && !inputs.dry_run - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: gitops-tags-${{ matrix.app.name }} path: gitops-tags/ diff --git a/src/build/docker-build-ts/action.yml b/src/build/docker-build-ts/action.yml index d9fbaeb1..7c585673 100644 --- a/src/build/docker-build-ts/action.yml +++ b/src/build/docker-build-ts/action.yml @@ -192,6 +192,7 @@ runs: echo " tags : ${{ steps.meta.outputs.tags }}" echo " registries : ${{ steps.image-names.outputs.images }}" + # Dry-run uses only linux/amd64 for fast validation (QEMU multi-platform is slow) - name: Build Docker image (dry run) if: ${{ inputs.dry-run == 'true' }} id: build-dry From 03ee1bee8b14d549c3fec3c5a299706cf8ac7561 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Mon, 9 Mar 2026 16:38:33 -0300 Subject: [PATCH 07/40] fix(build): fix helm version mismatch, harden prepare job inputs and tag validation --- .github/workflows/typescript-build.yml | 43 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml index 728c2226..1203734e 100644 --- a/.github/workflows/typescript-build.yml +++ b/.github/workflows/typescript-build.yml @@ -166,28 +166,44 @@ jobs: 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=$COMPONENTS_JSON" >> $GITHUB_OUTPUT + { + echo 'matrix<> "$GITHUB_OUTPUT" echo "has_builds=true" >> $GITHUB_OUTPUT - elif [ -z "${{ inputs.filter_paths }}" ]; then - APP_NAME="${{ github.event.repository.name }}" - echo "matrix=[{\"name\": \"${APP_NAME}\", \"working_dir\": \".\"}]" >> $GITHUB_OUTPUT + elif [ -z "$FILTER_PATHS" ]; then + echo "matrix=[{\"name\": \"${REPO_NAME}\", \"working_dir\": \".\"}]" >> $GITHUB_OUTPUT echo "has_builds=true" >> $GITHUB_OUTPUT else - MATRIX='${{ steps.changed-paths.outputs.matrix }}' - if [ "$MATRIX" == "[]" ] || [ -z "$MATRIX" ]; then + if [ "$CHANGED_MATRIX" == "[]" ] || [ -z "$CHANGED_MATRIX" ]; then echo "matrix=[]" >> $GITHUB_OUTPUT echo "has_builds=false" >> $GITHUB_OUTPUT else - echo "matrix=$MATRIX" >> $GITHUB_OUTPUT + { + 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 @@ -199,8 +215,19 @@ jobs: - 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 VERSION=$(echo "$TAG" | sed 's/.*-\(v[0-9]\)/\1/') @@ -292,7 +319,7 @@ jobs: helm_repository: ${{ inputs.helm_repository }} chart: ${{ inputs.helm_chart }} target_ref: ${{ inputs.helm_target_ref }} - version: ${{ github.ref_name }} + 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 }} From 5ddbc002668a6aad38dcff0937f5a40a4e7bc7ef Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Mon, 9 Mar 2026 17:12:36 -0300 Subject: [PATCH 08/40] fix(build): use per-app build context with fallback to global input --- .github/workflows/typescript-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml index 1203734e..ff941290 100644 --- a/.github/workflows/typescript-build.yml +++ b/.github/workflows/typescript-build.yml @@ -261,7 +261,7 @@ jobs: working-dir: ${{ matrix.app.working_dir }} dockerfile: ${{ matrix.app.dockerfile }} dockerfile-name: ${{ inputs.dockerfile_name }} - build-context: ${{ inputs.build_context }} + build-context: ${{ matrix.app.context || inputs.build_context }} build-secrets: ${{ inputs.build_secrets }} platforms: ${{ needs.prepare.outputs.platforms }} version: ${{ needs.prepare.outputs.version }} From 91c8cd3bd91c699426670f92b1a5c1106d56bfff Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 10 Mar 2026 10:19:51 -0300 Subject: [PATCH 09/40] chore(ci): standardize runner to blacksmith-4vcpu-ubuntu-2404 Replace ubuntu-latest with blacksmith-4vcpu-ubuntu-2404 across remaining workflows and add runner requirement to command rules. --- .claude/commands/composite.md | 19 +++++++++++++++++++ .claude/commands/gha.md | 22 ++++++++++++++++++++++ .claude/commands/workflow.md | 18 ++++++++++++++++++ .github/workflows/branch-cleanup.yml | 2 +- .github/workflows/dispatch-helm.yml | 2 +- .github/workflows/gptchangelog.yml | 2 +- .github/workflows/helm-update-chart.yml | 2 +- .github/workflows/labels-sync.yml | 2 +- 8 files changed, 64 insertions(+), 5 deletions(-) diff --git a/.claude/commands/composite.md b/.claude/commands/composite.md index ef8aa4b9..efaa9607 100644 --- a/.claude/commands/composite.md +++ b/.claude/commands/composite.md @@ -100,6 +100,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) diff --git a/.claude/commands/gha.md b/.claude/commands/gha.md index 90f12236..f8ec1961 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: diff --git a/.claude/commands/workflow.md b/.claude/commands/workflow.md index 6df05550..8a8cce56 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: diff --git a/.github/workflows/branch-cleanup.yml b/.github/workflows/branch-cleanup.yml index 09340f19..240c4c6d 100644 --- a/.github/workflows/branch-cleanup.yml +++ b/.github/workflows/branch-cleanup.yml @@ -45,7 +45,7 @@ permissions: jobs: cleanup: name: Clean up branches - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/dispatch-helm.yml b/.github/workflows/dispatch-helm.yml index 944c5470..7d7e8caf 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)' diff --git a/.github/workflows/gptchangelog.yml b/.github/workflows/gptchangelog.yml index 0334213c..f361ac6f 100644 --- a/.github/workflows/gptchangelog.yml +++ b/.github/workflows/gptchangelog.yml @@ -720,7 +720,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..aa81c970 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 diff --git a/.github/workflows/labels-sync.yml b/.github/workflows/labels-sync.yml index dd76d82a..0dde64ba 100644 --- a/.github/workflows/labels-sync.yml +++ b/.github/workflows/labels-sync.yml @@ -41,7 +41,7 @@ permissions: jobs: sync: name: Sync labels to repository - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Sync labels uses: ./src/config/labels-sync From 13e7761ebfac7d7c88b3a78c14814d6abf8e5372 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 10 Mar 2026 10:25:33 -0300 Subject: [PATCH 10/40] chore(ci): add runner rule to cursor rules --- .cursor/rules/composite-actions.mdc | 19 +++++++++++++++++++ .cursor/rules/reusable-workflows.mdc | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/.cursor/rules/composite-actions.mdc b/.cursor/rules/composite-actions.mdc index fe8a4531..d3bf74f1 100644 --- a/.cursor/rules/composite-actions.mdc +++ b/.cursor/rules/composite-actions.mdc @@ -112,6 +112,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) diff --git a/.cursor/rules/reusable-workflows.mdc b/.cursor/rules/reusable-workflows.mdc index e2413c0a..4c0bfaa7 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: From 9ab077533343a9d278fe4e00ef57c637024dd2eb Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 10 Mar 2026 15:30:46 -0300 Subject: [PATCH 11/40] feat(ci): add go-fuzz reusable workflow Add reusable workflow for Go fuzz testing with configurable command, Go version, artifact upload on failure, and dry_run support. --- .github/workflows/go-fuzz.yml | 98 +++++++++++++++++++++++++++++++++++ docs/go-fuzz.md | 71 +++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 .github/workflows/go-fuzz.yml create mode 100644 docs/go-fuzz.md diff --git a/.github/workflows/go-fuzz.yml b/.github/workflows/go-fuzz.yml new file mode 100644 index 00000000..9b095a24 --- /dev/null +++ b/.github/workflows/go-fuzz.yml @@ -0,0 +1,98 @@ +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 + 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 + 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 }} + + 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" + + - name: Run Fuzz Tests + if: ${{ !inputs.dry_run }} + run: ${{ inputs.fuzz_command }} + + - name: Upload Fuzz Artifacts + if: ${{ !inputs.dry_run && failure() }} + uses: actions/upload-artifact@v7 + with: + name: fuzz-failures + path: ${{ inputs.fuzz_artifacts_path }} + retention-days: ${{ inputs.artifacts_retention_days }} diff --git a/docs/go-fuzz.md b/docs/go-fuzz.md new file mode 100644 index 00000000..5c1cb032 --- /dev/null +++ b/docs/go-fuzz.md @@ -0,0 +1,71 @@ + + + + + +
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` | +| `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/' +``` + +## Permissions + +```yaml +permissions: + contents: read +``` From 70f8d877da2c68c69fedfefa8276e7bc39287afc Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 10 Mar 2026 15:45:07 -0300 Subject: [PATCH 12/40] fix(ci): scope fuzz artifact upload to fuzz step failure --- .github/workflows/go-fuzz.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go-fuzz.yml b/.github/workflows/go-fuzz.yml index 9b095a24..952f6cd8 100644 --- a/.github/workflows/go-fuzz.yml +++ b/.github/workflows/go-fuzz.yml @@ -86,11 +86,12 @@ jobs: echo " retention : ${{ inputs.artifacts_retention_days }} days" - name: Run Fuzz Tests + id: fuzz if: ${{ !inputs.dry_run }} run: ${{ inputs.fuzz_command }} - name: Upload Fuzz Artifacts - if: ${{ !inputs.dry_run && failure() }} + if: ${{ !inputs.dry_run && steps.fuzz.outcome == 'failure' }} uses: actions/upload-artifact@v7 with: name: fuzz-failures From c389b2a62ebe162412f8731b60356895e6e6a15c Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 10 Mar 2026 16:09:11 -0300 Subject: [PATCH 13/40] feat(ci): add timeout safety net and document fuzz command requirements --- .github/workflows/go-fuzz.yml | 10 ++++++++++ docs/go-fuzz.md | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/.github/workflows/go-fuzz.yml b/.github/workflows/go-fuzz.yml index 952f6cd8..0204e8a0 100644 --- a/.github/workflows/go-fuzz.yml +++ b/.github/workflows/go-fuzz.yml @@ -26,6 +26,10 @@ on: 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 @@ -53,6 +57,10 @@ on: 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 @@ -65,6 +73,7 @@ jobs: fuzz: name: Fuzz Tests runs-on: ${{ inputs.runner_type }} + timeout-minutes: ${{ inputs.timeout_minutes }} steps: - name: Checkout @@ -84,6 +93,7 @@ jobs: 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 diff --git a/docs/go-fuzz.md b/docs/go-fuzz.md index 5c1cb032..f96f8f40 100644 --- a/docs/go-fuzz.md +++ b/docs/go-fuzz.md @@ -16,6 +16,7 @@ Reusable workflow for running Go fuzz tests. Executes a configurable fuzz comman | `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 @@ -63,6 +64,18 @@ jobs: 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 From d7b516da54c391f686ddb349ff09be423eb726ce Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 10 Mar 2026 17:09:55 -0300 Subject: [PATCH 14/40] feat(ci): add release-notification reusable workflow with Discord and Slack composites --- .github/dependabot.yml | 3 +- .github/labels.yml | 4 + .github/workflows/release-notification.yml | 164 +++++++++++++++++++++ docs/release-notification.md | 120 +++++++++++++++ src/notify/discord-release/README.md | 64 ++++++++ src/notify/discord-release/action.yml | 77 ++++++++++ src/notify/slack-release/README.md | 68 +++++++++ src/notify/slack-release/action.yml | 53 +++++++ 8 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-notification.yml create mode 100644 docs/release-notification.md create mode 100644 src/notify/discord-release/README.md create mode 100644 src/notify/discord-release/action.yml create mode 100644 src/notify/slack-release/README.md create mode 100644 src/notify/slack-release/action.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2b327e99..24925fe6 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" diff --git a/.github/labels.yml b/.github/labels.yml index cfb642e3..2983621e 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -83,3 +83,7 @@ - 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/) diff --git a/.github/workflows/release-notification.yml b/.github/workflows/release-notification.yml new file mode 100644 index 00000000..0a3f9ea8 --- /dev/null +++ b/.github/workflows/release-notification.yml @@ -0,0 +1,164 @@ +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 + steps: + - name: Create GitHub App token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch latest release tag + id: release + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + TAG=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 1 --json tagName --jq '.[0].tagName') + echo "Raw release: $TAG" + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Dry run summary + if: ${{ inputs.dry_run }} + run: | + 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 : ${{ secrets.DISCORD_WEBHOOK_URL != '' && 'configured' || 'not set' }}" + echo " slack_webhook : ${{ secrets.SLACK_WEBHOOK_URL != '' && 'configured' || 'not set' }}" + echo " slack_channel : ${{ inputs.slack_channel }}" + echo " skip_beta_discord: ${{ inputs.skip_beta_discord }}" + + - name: Discord notification + if: ${{ secrets.DISCORD_WEBHOOK_URL != '' }} + uses: ./src/notify/discord-release + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + 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: ${{ secrets.SLACK_WEBHOOK_URL != '' && inputs.slack_channel != '' }} + uses: ./src/notify/slack-release + 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/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/src/notify/discord-release/README.md b/src/notify/discord-release/README.md new file mode 100644 index 00000000..9d8d234a --- /dev/null +++ b/src/notify/discord-release/README.md @@ -0,0 +1,64 @@ + + + + + +
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 | — | +| `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 }} + 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 }} + 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..6f739acc --- /dev/null +++ b/src/notify/discord-release/action.yml @@ -0,0 +1,77 @@ +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" + 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: + - name: Detect beta release + id: beta + shell: bash + run: | + if [[ "${{ github.ref }}" == *"-beta."* ]]; then + echo "is_beta=true" >> $GITHUB_OUTPUT + else + echo "is_beta=false" >> $GITHUB_OUTPUT + fi + + - 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 " 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 }}" + echo " ref : ${{ github.ref }}" + + - 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/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..6739b287 --- /dev/null +++ b/src/notify/slack-release/action.yml @@ -0,0 +1,53 @@ +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: + - 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 }}" + + - 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 }} From 804c76a058478a6486deb72befe135d5ec8b0353 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 10 Mar 2026 17:43:05 -0300 Subject: [PATCH 15/40] fix(ci): address CodeRabbit review on release-notification workflow - Prefer github.event.release.tag_name with fallback to gh release list - Map secrets to job-level env vars (secrets context unavailable in step if:) - Detect beta via release-tag input instead of github.ref - Complete dry-run summary with all resolved inputs --- .github/workflows/release-notification.yml | 31 +++++++++++++++++----- src/notify/discord-release/README.md | 3 +++ src/notify/discord-release/action.yml | 19 +++++++------ 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-notification.yml b/.github/workflows/release-notification.yml index 0a3f9ea8..dca8068c 100644 --- a/.github/workflows/release-notification.yml +++ b/.github/workflows/release-notification.yml @@ -109,6 +109,9 @@ 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@v1 @@ -125,8 +128,16 @@ jobs: env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} run: | - TAG=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 1 --json tagName --jq '.[0].tagName') - echo "Raw release: $TAG" + 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 @@ -135,16 +146,22 @@ jobs: 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 : ${{ secrets.DISCORD_WEBHOOK_URL != '' && 'configured' || 'not set' }}" - echo " slack_webhook : ${{ secrets.SLACK_WEBHOOK_URL != '' && 'configured' || 'not set' }}" - echo " slack_channel : ${{ inputs.slack_channel }}" + 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 " 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 }}" - name: Discord notification - if: ${{ secrets.DISCORD_WEBHOOK_URL != '' }} + if: ${{ env.DISCORD_WEBHOOK_URL != '' }} uses: ./src/notify/discord-release 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 }} @@ -152,7 +169,7 @@ jobs: dry-run: ${{ inputs.dry_run }} - name: Slack notification - if: ${{ secrets.SLACK_WEBHOOK_URL != '' && inputs.slack_channel != '' }} + if: ${{ env.SLACK_WEBHOOK_URL != '' && inputs.slack_channel != '' }} uses: ./src/notify/slack-release with: webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/src/notify/discord-release/README.md b/src/notify/discord-release/README.md index 9d8d234a..566bec12 100644 --- a/src/notify/discord-release/README.md +++ b/src/notify/discord-release/README.md @@ -12,6 +12,7 @@ Composite action that sends a release notification to a Discord channel via webh | 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 | `""` | @@ -32,6 +33,7 @@ jobs: - 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>" ``` @@ -56,6 +58,7 @@ jobs: - 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" ``` diff --git a/src/notify/discord-release/action.yml b/src/notify/discord-release/action.yml index 6f739acc..8a3af63c 100644 --- a/src/notify/discord-release/action.yml +++ b/src/notify/discord-release/action.yml @@ -21,6 +21,9 @@ inputs: 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 @@ -37,7 +40,7 @@ runs: id: beta shell: bash run: | - if [[ "${{ github.ref }}" == *"-beta."* ]]; then + if [[ "${{ inputs.release-tag }}" == *"-beta."* ]]; then echo "is_beta=true" >> $GITHUB_OUTPUT else echo "is_beta=false" >> $GITHUB_OUTPUT @@ -48,13 +51,13 @@ runs: shell: bash run: | echo "::notice::DRY RUN — Discord notification will not be sent" - echo " webhook : (configured)" - 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 }}" - echo " ref : ${{ github.ref }}" + 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 }}" - name: Send Discord notification if: >- From 1e66d3dd440cec4337e7ca3a7bec86645eb83bfb Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Tue, 10 Mar 2026 18:20:54 -0300 Subject: [PATCH 16/40] fix(ci): use external refs for composites in release-notification workflow Composite actions referenced via `uses: ./path` in reusable workflows resolve to the caller's workspace, not the called repo. Changed to external refs (@develop) matching the typescript-build.yml pattern. Also corrected the local path rule in AGENTS.md and .cursor/rules/reusable-workflows.mdc to document the correct behavior. --- .cursor/rules/reusable-workflows.mdc | 21 +++++++++++++-------- .github/workflows/release-notification.yml | 4 ++-- AGENTS.md | 21 +++++++++++++-------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/.cursor/rules/reusable-workflows.mdc b/.cursor/rules/reusable-workflows.mdc index 4c0bfaa7..a4a9619d 100644 --- a/.cursor/rules/reusable-workflows.mdc +++ b/.cursor/rules/reusable-workflows.mdc @@ -195,14 +195,19 @@ 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) +## Composite action references (critical) -Inside a reusable workflow, always reference composites 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). -```yaml -uses: ./src/setup/setup-go # ✅ composite version matches workflow version -uses: LerianStudio/...@main # ❌ breaks versioning for callers on older tags -``` +- **Workflows called by external repos** — must use an external ref: + ```yaml + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@develop # ✅ + 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 @@ -292,8 +297,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 diff --git a/.github/workflows/release-notification.yml b/.github/workflows/release-notification.yml index dca8068c..01e6d302 100644 --- a/.github/workflows/release-notification.yml +++ b/.github/workflows/release-notification.yml @@ -158,7 +158,7 @@ jobs: - name: Discord notification if: ${{ env.DISCORD_WEBHOOK_URL != '' }} - uses: ./src/notify/discord-release + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@develop with: webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} release-tag: ${{ steps.release.outputs.tag }} @@ -170,7 +170,7 @@ jobs: - name: Slack notification if: ${{ env.SLACK_WEBHOOK_URL != '' && inputs.slack_channel != '' }} - uses: ./src/notify/slack-release + uses: LerianStudio/github-actions-shared-workflows/src/notify/slack-release@develop with: webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} channel: ${{ inputs.slack_channel }} diff --git a/AGENTS.md b/AGENTS.md index 3f9ab3ca..94f62df3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,14 +36,19 @@ 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 - -Inside a reusable workflow, always reference composite actions with a **local path**: - -```yaml -uses: ./src/config/labels-sync # ✅ version-safe -uses: LerianStudio/...@main # ❌ breaks versioning for callers on older tags -``` +### Composite action references in reusable workflows + +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: + ```yaml + uses: LerianStudio/github-actions-shared-workflows/src/notify/discord-release@develop # ✅ + 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 + ``` ### dry_run From 234e15aba0fb9e9808af9c66fd264b9c243f6aea Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 11 Mar 2026 14:08:21 -0300 Subject: [PATCH 17/40] fix(ci): add has_builds output to build workflow and update external ref docs --- .github/workflows/build.yml | 4 ++++ AGENTS.md | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9154aa6d..0c7775b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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' diff --git a/AGENTS.md b/AGENTS.md index 94f62df3..7c4ccf2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,9 +40,10 @@ Full rules: 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: +- **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@develop # ✅ + 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: From ad751c21c8271cd2db9c89eb3cce8c69ed87788b Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 11 Mar 2026 14:13:02 -0300 Subject: [PATCH 18/40] =?UTF-8?q?fix(ci):=20address=20CodeRabbit=20review?= =?UTF-8?q?=20=E2=80=94=20markdownlint,=20pin=20refs,=20dry-run=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/reusable-workflows.mdc | 5 +++-- .github/workflows/release-notification.yml | 4 ++++ AGENTS.md | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.cursor/rules/reusable-workflows.mdc b/.cursor/rules/reusable-workflows.mdc index a4a9619d..f518984c 100644 --- a/.cursor/rules/reusable-workflows.mdc +++ b/.cursor/rules/reusable-workflows.mdc @@ -199,9 +199,10 @@ The two modes have opposite goals — design them accordingly: 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: +- **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@develop # ✅ + 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: diff --git a/.github/workflows/release-notification.yml b/.github/workflows/release-notification.yml index 01e6d302..420728e8 100644 --- a/.github/workflows/release-notification.yml +++ b/.github/workflows/release-notification.yml @@ -143,6 +143,8 @@ jobs: - 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 }}" @@ -151,10 +153,12 @@ jobs: 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 != '' }} diff --git a/AGENTS.md b/AGENTS.md index 7c4ccf2e..bf3c31da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,12 +41,15 @@ Full rules: 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 ``` From aee97adc93aaca2be4c99e3012d203882fe9f334 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 11 Mar 2026 14:20:29 -0300 Subject: [PATCH 19/40] docs(ci): add skip-enabling outputs rule for workflows and composites --- .cursor/rules/composite-actions.mdc | 13 ++++++++++++ .cursor/rules/reusable-workflows.mdc | 27 ++++++++++++++++++++++++ AGENTS.md | 31 +++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.cursor/rules/composite-actions.mdc b/.cursor/rules/composite-actions.mdc index d3bf74f1..e2db6e75 100644 --- a/.cursor/rules/composite-actions.mdc +++ b/.cursor/rules/composite-actions.mdc @@ -85,6 +85,19 @@ runs: option: ${{ inputs.some-option }} ``` +## 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'`. + ## Design rules - **5–15 steps maximum** — split if larger diff --git a/.cursor/rules/reusable-workflows.mdc b/.cursor/rules/reusable-workflows.mdc index f518984c..6d0bdfb0 100644 --- a/.cursor/rules/reusable-workflows.mdc +++ b/.cursor/rules/reusable-workflows.mdc @@ -195,6 +195,33 @@ 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 +## 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'`. + +Callers use these outputs to gate dependent jobs: + +```yaml +jobs: + build: + uses: ./.github/workflows/build.yml@v1.2.3 + deploy: + needs: build + if: needs.build.outputs.has_builds == 'true' + uses: ./.github/workflows/deploy.yml@v1.2.3 +``` + ## 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). diff --git a/AGENTS.md b/AGENTS.md index bf3c31da..1299df40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,9 +54,38 @@ In reusable workflows (`workflow_call`), `uses: ./path` resolves to the **caller 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 +jobs: + build: + uses: ./.github/workflows/build.yml@v1.2.3 + deploy: + needs: build + if: needs.build.outputs.has_builds == 'true' +``` + ### 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 From 409742255ec20781025819ee653aaf382550f60e Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 11 Mar 2026 14:33:17 -0300 Subject: [PATCH 20/40] fix(docs): use external refs in skip-enabling output examples --- .cursor/rules/reusable-workflows.mdc | 4 ++-- AGENTS.md | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.cursor/rules/reusable-workflows.mdc b/.cursor/rules/reusable-workflows.mdc index 6d0bdfb0..5021af2f 100644 --- a/.cursor/rules/reusable-workflows.mdc +++ b/.cursor/rules/reusable-workflows.mdc @@ -215,11 +215,11 @@ Callers use these outputs to gate dependent jobs: ```yaml jobs: build: - uses: ./.github/workflows/build.yml@v1.2.3 + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/build.yml@v1.2.3 deploy: needs: build if: needs.build.outputs.has_builds == 'true' - uses: ./.github/workflows/deploy.yml@v1.2.3 + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/deploy.yml@v1.2.3 ``` ## Composite action references (critical) diff --git a/AGENTS.md b/AGENTS.md index 1299df40..f28d7149 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,10 +77,11 @@ Callers use these outputs to gate dependent jobs: ```yaml jobs: build: - uses: ./.github/workflows/build.yml@v1.2.3 + 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 From 337a6d203fa3c37469523683aa5775e1c5513d10 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 11 Mar 2026 16:10:26 -0300 Subject: [PATCH 21/40] feat(ci): add changed-paths composite action and refactor workflow to use it --- .github/workflows/changed-paths.yml | 201 +++++++++------------- docs/changed-paths-workflow.md | 255 ---------------------------- docs/changed-paths.md | 119 +++++++++++++ src/config/changed-paths/README.md | 143 ++++++++++++++++ src/config/changed-paths/action.yml | 194 +++++++++++++++++++++ 5 files changed, 540 insertions(+), 372 deletions(-) delete mode 100644 docs/changed-paths-workflow.md create mode 100644 docs/changed-paths.md create mode 100644 src/config/changed-paths/README.md create mode 100644 src/config/changed-paths/action.yml diff --git a/.github/workflows/changed-paths.yml b/.github/workflows/changed-paths.yml index 5758c356..4ebe9c77 100644 --- a/.github/workflows/changed-paths.yml +++ b/.github/workflows/changed-paths.yml @@ -23,11 +23,26 @@ on: required: false type: string 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 + type: string + default: '' + normalize_to_filter: + description: 'If true, uses the filter path as working_dir instead of the actual trimmed directory path' + required: false + type: boolean + default: false runner_type: description: 'GitHub runner type' required: false type: string default: 'blacksmith-4vcpu-ubuntu-2404' + dry_run: + description: 'Preview changes without applying them' + required: false + type: boolean + default: false outputs: matrix: @@ -37,131 +52,83 @@ on: description: 'Boolean indicating if there are any changes' value: ${{ jobs.get-changed-paths.outputs.has_changes }} + workflow_dispatch: + 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: '' + 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 + type: string + default: '' + normalize_to_filter: + description: 'If true, uses the filter path as working_dir instead of the actual trimmed directory path' + required: false + type: boolean + default: false + runner_type: + description: 'GitHub runner type' + required: false + type: string + default: 'blacksmith-4vcpu-ubuntu-2404' + dry_run: + description: 'Preview changes without applying them' + type: boolean + default: false + 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 }} + matrix: ${{ steps.changed-paths.outputs.matrix }} + has_changes: ${{ steps.changed-paths.outputs.has_changes }} steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Get changed files - id: changed + - name: Dry run notice + if: ${{ inputs.dry_run }} 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 + echo "::notice::DRY RUN — changed-paths detection" + echo " filter_paths : ${{ inputs.filter_paths }}" + echo " path_level : ${{ inputs.path_level }}" + echo " get_app_name : ${{ inputs.get_app_name }}" + echo " app_name_prefix : ${{ inputs.app_name_prefix }}" + echo " app_name_overrides : ${{ inputs.app_name_overrides }}" + echo " normalize_to_filter : ${{ inputs.normalize_to_filter }}" + + - name: Get changed paths + id: changed-paths + uses: ./src/config/changed-paths + with: + filter_paths: ${{ inputs.filter_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 }} + + - name: Dry run result + if: ${{ inputs.dry_run }} 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" + echo "::notice::DRY RUN — matrix result: ${{ steps.changed-paths.outputs.matrix }}" + echo "::notice::DRY RUN — has_changes: ${{ steps.changed-paths.outputs.has_changes }}" 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/changed-paths.md b/docs/changed-paths.md new file mode 100644 index 00000000..db570a5a --- /dev/null +++ b/docs/changed-paths.md @@ -0,0 +1,119 @@ + + + + + +
Lerian

changed-paths

+ +Reusable workflow for detecting changed paths between commits. Wraps the [`src/config/changed-paths`](../src/config/changed-paths/) composite action, adding `dry_run` support and `workflow_dispatch` for manual testing. + +## Usage + +### Basic Usage + +```yaml +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: blacksmith-4vcpu-ubuntu-2404 + strategy: + matrix: + path: ${{ fromJson(needs.detect-changes.outputs.matrix) }} + steps: + - name: Build changed component + run: echo "Building ${{ matrix.path }}" +``` + +### Monorepo with Path Filtering + +```yaml +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 +``` + +### With App Name Generation + +```yaml +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: blacksmith-4vcpu-ubuntu-2404 + strategy: + matrix: + app: ${{ fromJson(needs.detect-changes.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + - name: Deploy + run: | + echo "Deploying app: ${{ matrix.app.name }}" + echo "Working directory: ${{ matrix.app.working_dir }}" +``` + +### With App Name Overrides + +```yaml +jobs: + detect-changes: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 + with: + filter_paths: '["components/onboarding", "components/transaction"]' + path_level: 2 + get_app_name: true + app_name_prefix: 'midaz' + app_name_overrides: |- + components/onboarding: + components/transaction:tx +``` + +### Dry Run + +```yaml +jobs: + test: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@develop + with: + filter_paths: '["src/"]' + path_level: 2 + dry_run: true +``` + +## 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 | `''` | +| `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` | +| `runner_type` | GitHub runner type | No | `blacksmith-4vcpu-ubuntu-2404` | +| `dry_run` | Preview changes without applying them | No | `false` | + +## 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 | + +## Related + +- [Composite action documentation](../src/config/changed-paths/README.md) diff --git a/src/config/changed-paths/README.md b/src/config/changed-paths/README.md new file mode 100644 index 00000000..4c36b457 --- /dev/null +++ b/src/config/changed-paths/README.md @@ -0,0 +1,143 @@ + + + + + +
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 + +| 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 | `''` | +| `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` | + +## 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' +``` + +## Usage as reusable workflow + +Use the `changed-paths.yml` reusable workflow which wraps this composite: + +```yaml +jobs: + detect-changes: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 + with: + filter_paths: '["components/api", "components/web"]' + path_level: 2 + get_app_name: true + app_name_prefix: 'myapp' + + build: + needs: detect-changes + if: needs.detect-changes.outputs.has_changes == 'true' + strategy: + matrix: + app: ${{ fromJson(needs.detect-changes.outputs.matrix) }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v6 + - run: echo "Building ${{ matrix.app.name }} at ${{ matrix.app.working_dir }}" +``` + +## 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 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..a305bfe9 --- /dev/null +++ b/src/config/changed-paths/action.yml @@ -0,0 +1,194 @@ +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: 'JSON array of path prefixes to filter results (e.g., ["components/mdz", "components/transaction"])' + 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' + +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 + # 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 }}" + APP_NAME_OVERRIDES="${{ inputs.app_name_overrides }}" + NORMALIZE_TO_FILTER="${{ inputs.normalize_to_filter }}" + + # 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 + + # 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 + # 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 + [[ -z "$FILTER" ]] && continue + if [[ "$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 + + # 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 + # 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 + + echo "Changed directories matrix: $MATRIX" + printf "matrix=%s\n" "$MATRIX" >> "$GITHUB_OUTPUT" + printf "has_changes=true\n" >> "$GITHUB_OUTPUT" From 7b78c652b8b6d495958413280a6b1aa102bcecde Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 11 Mar 2026 16:20:23 -0300 Subject: [PATCH 22/40] refactor(ci): remove changed-paths reusable workflow and migrate release to composite --- .github/workflows/changed-paths.yml | 134 ---------------------------- .github/workflows/release.yml | 2 +- docs/changed-paths.md | 119 ------------------------ src/config/changed-paths/README.md | 26 ------ 4 files changed, 1 insertion(+), 280 deletions(-) delete mode 100644 .github/workflows/changed-paths.yml delete mode 100644 docs/changed-paths.md diff --git a/.github/workflows/changed-paths.yml b/.github/workflows/changed-paths.yml deleted file mode 100644 index 4ebe9c77..00000000 --- a/.github/workflows/changed-paths.yml +++ /dev/null @@ -1,134 +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: '' - 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 - type: string - default: '' - normalize_to_filter: - description: 'If true, uses the filter path as working_dir instead of the actual trimmed directory path' - required: false - type: boolean - default: false - runner_type: - description: 'GitHub runner type' - required: false - type: string - default: 'blacksmith-4vcpu-ubuntu-2404' - dry_run: - description: 'Preview changes without applying them' - required: false - type: boolean - default: false - - 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 }} - - workflow_dispatch: - 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: '' - 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 - type: string - default: '' - normalize_to_filter: - description: 'If true, uses the filter path as working_dir instead of the actual trimmed directory path' - required: false - type: boolean - default: false - runner_type: - description: 'GitHub runner type' - required: false - type: string - default: 'blacksmith-4vcpu-ubuntu-2404' - dry_run: - description: 'Preview changes without applying them' - type: boolean - default: false - -jobs: - get-changed-paths: - name: Get Changed Paths - runs-on: ${{ inputs.runner_type }} - outputs: - matrix: ${{ steps.changed-paths.outputs.matrix }} - has_changes: ${{ steps.changed-paths.outputs.has_changes }} - - steps: - - name: Dry run notice - if: ${{ inputs.dry_run }} - shell: bash - run: | - echo "::notice::DRY RUN — changed-paths detection" - echo " filter_paths : ${{ inputs.filter_paths }}" - echo " path_level : ${{ inputs.path_level }}" - echo " get_app_name : ${{ inputs.get_app_name }}" - echo " app_name_prefix : ${{ inputs.app_name_prefix }}" - echo " app_name_overrides : ${{ inputs.app_name_overrides }}" - echo " normalize_to_filter : ${{ inputs.normalize_to_filter }}" - - - name: Get changed paths - id: changed-paths - uses: ./src/config/changed-paths - with: - filter_paths: ${{ inputs.filter_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 }} - - - name: Dry run result - if: ${{ inputs.dry_run }} - shell: bash - run: | - echo "::notice::DRY RUN — matrix result: ${{ steps.changed-paths.outputs.matrix }}" - echo "::notice::DRY RUN — has_changes: ${{ steps.changed-paths.outputs.has_changes }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65009054..364281f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,7 @@ 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 }} diff --git a/docs/changed-paths.md b/docs/changed-paths.md deleted file mode 100644 index db570a5a..00000000 --- a/docs/changed-paths.md +++ /dev/null @@ -1,119 +0,0 @@ - - - - - -
Lerian

changed-paths

- -Reusable workflow for detecting changed paths between commits. Wraps the [`src/config/changed-paths`](../src/config/changed-paths/) composite action, adding `dry_run` support and `workflow_dispatch` for manual testing. - -## Usage - -### Basic Usage - -```yaml -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: blacksmith-4vcpu-ubuntu-2404 - strategy: - matrix: - path: ${{ fromJson(needs.detect-changes.outputs.matrix) }} - steps: - - name: Build changed component - run: echo "Building ${{ matrix.path }}" -``` - -### Monorepo with Path Filtering - -```yaml -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 -``` - -### With App Name Generation - -```yaml -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: blacksmith-4vcpu-ubuntu-2404 - strategy: - matrix: - app: ${{ fromJson(needs.detect-changes.outputs.matrix) }} - steps: - - uses: actions/checkout@v6 - - name: Deploy - run: | - echo "Deploying app: ${{ matrix.app.name }}" - echo "Working directory: ${{ matrix.app.working_dir }}" -``` - -### With App Name Overrides - -```yaml -jobs: - detect-changes: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 - with: - filter_paths: '["components/onboarding", "components/transaction"]' - path_level: 2 - get_app_name: true - app_name_prefix: 'midaz' - app_name_overrides: |- - components/onboarding: - components/transaction:tx -``` - -### Dry Run - -```yaml -jobs: - test: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@develop - with: - filter_paths: '["src/"]' - path_level: 2 - dry_run: true -``` - -## 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 | `''` | -| `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` | -| `runner_type` | GitHub runner type | No | `blacksmith-4vcpu-ubuntu-2404` | -| `dry_run` | Preview changes without applying them | No | `false` | - -## 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 | - -## Related - -- [Composite action documentation](../src/config/changed-paths/README.md) diff --git a/src/config/changed-paths/README.md b/src/config/changed-paths/README.md index 4c36b457..ae8cf948 100644 --- a/src/config/changed-paths/README.md +++ b/src/config/changed-paths/README.md @@ -39,32 +39,6 @@ steps: app_name_prefix: 'myapp' ``` -## Usage as reusable workflow - -Use the `changed-paths.yml` reusable workflow which wraps this composite: - -```yaml -jobs: - detect-changes: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/changed-paths.yml@v1.0.0 - with: - filter_paths: '["components/api", "components/web"]' - path_level: 2 - get_app_name: true - app_name_prefix: 'myapp' - - build: - needs: detect-changes - if: needs.detect-changes.outputs.has_changes == 'true' - strategy: - matrix: - app: ${{ fromJson(needs.detect-changes.outputs.matrix) }} - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@v6 - - run: echo "Building ${{ matrix.app.name }} at ${{ matrix.app.working_dir }}" -``` - ## Output formats ### Default (get_app_name: false) From 41c93c488f31c8e33ddbcd73e1b36fc79d414ff9 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Wed, 11 Mar 2026 17:00:43 -0300 Subject: [PATCH 23/40] fix(ci): compare against previous tag instead of HEAD^ on tag pushes --- src/config/changed-paths/action.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/config/changed-paths/action.yml b/src/config/changed-paths/action.yml index a305bfe9..3143e327 100644 --- a/src/config/changed-paths/action.yml +++ b/src/config/changed-paths/action.yml @@ -51,13 +51,27 @@ runs: # 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 - # 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) + # Tag push or missing before ref + CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "") + if [[ -n "$CURRENT_TAG" ]]; then + # Compare against previous tag to capture all changes since last release + PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -1) + if [[ -n "$PREV_TAG" ]]; then + echo "Tag push detected: comparing $PREV_TAG..$CURRENT_TAG" + FILES=$(git diff --name-only "$PREV_TAG" HEAD) + else + # First tag ever — list all files + FILES=$(git ls-tree -r --name-only HEAD) + fi else - # Fallback for first commit - FILES=$(git ls-tree -r --name-only HEAD) + # 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 From 10dffbd6a7adef5d21d07b8ba291d35aea7ab52f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:31:43 -0300 Subject: [PATCH 24/40] chore(deps): bump the release group with 2 updates (#117) Bumps the release group with 2 updates: [crazy-max/ghaction-import-gpg](https://github.com/crazy-max/ghaction-import-gpg) and [actions/create-github-app-token](https://github.com/actions/create-github-app-token). Updates `crazy-max/ghaction-import-gpg` from 6 to 7 - [Release notes](https://github.com/crazy-max/ghaction-import-gpg/releases) - [Commits](https://github.com/crazy-max/ghaction-import-gpg/compare/v6...v7) Updates `actions/create-github-app-token` from 1 to 2 - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Commits](https://github.com/actions/create-github-app-token/compare/v1...v2) --- updated-dependencies: - dependency-name: crazy-max/ghaction-import-gpg dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: release - dependency-name: actions/create-github-app-token dependency-version: '2' dependency-type: direct:production update-type: version-update:semver-major dependency-group: release ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/gitops-update.yml | 2 +- .github/workflows/gptchangelog.yml | 4 ++-- .github/workflows/helm-update-chart.yml | 4 ++-- .github/workflows/release-notification.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/typescript-release.yml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/gitops-update.yml b/.github/workflows/gitops-update.yml index 5bad6822..362adc9c 100644 --- a/.github/workflows/gitops-update.yml +++ b/.github/workflows/gitops-update.yml @@ -414,7 +414,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/gptchangelog.yml b/.github/workflows/gptchangelog.yml index f361ac6f..b309bfb2 100644 --- a/.github/workflows/gptchangelog.yml +++ b/.github/workflows/gptchangelog.yml @@ -245,7 +245,7 @@ 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 }} @@ -271,7 +271,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 }} diff --git a/.github/workflows/helm-update-chart.yml b/.github/workflows/helm-update-chart.yml index aa81c970..3bbc3ce6 100644 --- a/.github/workflows/helm-update-chart.yml +++ b/.github/workflows/helm-update-chart.yml @@ -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 }} @@ -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 }} diff --git a/.github/workflows/release-notification.yml b/.github/workflows/release-notification.yml index 420728e8..4a9f3f90 100644 --- a/.github/workflows/release-notification.yml +++ b/.github/workflows/release-notification.yml @@ -114,7 +114,7 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 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.APP_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 364281f5..3f50bbd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,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/typescript-release.yml b/.github/workflows/typescript-release.yml index 5eedec31..83cf456f 100644 --- a/.github/workflows/typescript-release.yml +++ b/.github/workflows/typescript-release.yml @@ -126,7 +126,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 }} From 9566d76f3104d3e4be6c1897009b5f16c74587a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:31:57 -0300 Subject: [PATCH 25/40] chore(deps): bump goreleaser/goreleaser-action in the go-tooling group (#116) Bumps the go-tooling group with 1 update: [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action). Updates `goreleaser/goreleaser-action` from 6 to 7 - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6...v7) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: go-tooling ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/go-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml index fa2735b2..19e9fb39 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 }} From eb764820860fc987e79bfdbbcb97abd77c97fe30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:32:12 -0300 Subject: [PATCH 26/40] chore(deps): bump the security-scanners group with 2 updates (#115) Bumps the security-scanners group with 2 updates: [securego/gosec](https://github.com/securego/gosec) and [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action). Updates `securego/gosec` from 2.22.11 to 2.24.7 - [Release notes](https://github.com/securego/gosec/releases) - [Commits](https://github.com/securego/gosec/compare/v2.22.11...v2.24.7) Updates `aquasecurity/trivy-action` from 0.34.1 to 0.34.2 - [Release notes](https://github.com/aquasecurity/trivy-action/releases) - [Commits](https://github.com/aquasecurity/trivy-action/compare/0.34.1...0.34.2) --- updated-dependencies: - dependency-name: securego/gosec dependency-version: 2.24.7 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: security-scanners - dependency-name: aquasecurity/trivy-action dependency-version: 0.34.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: security-scanners ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/go-pr-analysis.yml | 2 +- .github/workflows/go-security.yml | 2 +- .github/workflows/pr-security-scan.yml | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/go-pr-analysis.yml b/.github/workflows/go-pr-analysis.yml index d839c625..fd97aca7 100644 --- a/.github/workflows/go-pr-analysis.yml +++ b/.github/workflows/go-pr-analysis.yml @@ -317,7 +317,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 }}/... diff --git a/.github/workflows/go-security.yml b/.github/workflows/go-security.yml index e35a7253..e7fd5619 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: '.' diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 82229b16..897981b2 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -138,7 +138,7 @@ jobs: # ----------------- Security Scans ----------------- - name: Trivy Secret Scan - Component (Table Output) - uses: aquasecurity/trivy-action@0.34.1 + uses: aquasecurity/trivy-action@0.35.0 if: always() with: scan-type: fs @@ -150,7 +150,7 @@ jobs: version: 'v0.69.2' - name: Trivy Secret Scan - Component (SARIF Output) - uses: aquasecurity/trivy-action@0.34.1 + uses: aquasecurity/trivy-action@0.35.0 if: always() with: scan-type: fs @@ -178,7 +178,7 @@ jobs: - name: Trivy Vulnerability Scan - Docker Image (Table Output) if: always() && inputs.enable_docker_scan - uses: aquasecurity/trivy-action@0.34.1 + uses: aquasecurity/trivy-action@0.35.0 with: image-ref: '${{ env.DOCKERHUB_ORG }}/${{ env.APP_NAME }}:pr-scan-${{ github.sha }}' format: 'table' @@ -190,7 +190,7 @@ jobs: - name: Trivy Vulnerability Scan - Docker Image (SARIF Output) if: always() && inputs.enable_docker_scan - uses: aquasecurity/trivy-action@0.34.1 + uses: aquasecurity/trivy-action@0.35.0 with: image-ref: '${{ env.DOCKERHUB_ORG }}/${{ env.APP_NAME }}:pr-scan-${{ github.sha }}' format: sarif @@ -204,7 +204,7 @@ jobs: # ----------------- Filesystem Vulnerability Scan ----------------- - name: Trivy Vulnerability Scan - Filesystem (JSON Output) id: fs-vuln-scan - uses: aquasecurity/trivy-action@0.34.1 + uses: aquasecurity/trivy-action@0.35.0 if: always() with: scan-type: fs From fb0eaa035fad1157d50995305162d0509d7fd19f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:32:26 -0300 Subject: [PATCH 27/40] Merge pull request #114 from LerianStudio/dependabot/github_actions/develop/github-security-1893dd32ff chore(deps): bump github/codeql-action from 3 to 4 in the github-security group --- .github/workflows/typescript-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/typescript-ci.yml b/.github/workflows/typescript-ci.yml index 78baa841..61784110 100644 --- a/.github/workflows/typescript-ci.yml +++ b/.github/workflows/typescript-ci.yml @@ -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 From 2d7febed07e806712b08a84841fbfbf5fdba5815 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:32:39 -0300 Subject: [PATCH 28/40] Merge pull request #113 from LerianStudio/dependabot/github_actions/develop/docker-1590fac0fc chore(deps): bump the docker group with 5 updates --- .github/workflows/build.yml | 12 ++++++------ .github/workflows/gitops-update.yml | 2 +- .github/workflows/go-release.yml | 8 ++++---- .github/workflows/pr-security-scan.yml | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c7775b9..373ffc46 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -190,21 +190,21 @@ jobs: - name: Set up QEMU if: needs.prepare.outputs.is_release == 'true' - uses: docker/setup-qemu-action@v3 + 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 }} @@ -253,7 +253,7 @@ jobs: - name: Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ steps.image-names.outputs.images }} tags: | @@ -262,7 +262,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 }} diff --git a/.github/workflows/gitops-update.yml b/.github/workflows/gitops-update.yml index 362adc9c..c43004fa 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 }} diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml index 19e9fb39..cf81a63d 100644 --- a/.github/workflows/go-release.yml +++ b/.github/workflows/go-release.yml @@ -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/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 897981b2..ceb83c05 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -60,7 +60,7 @@ jobs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ inputs.docker_registry }} username: ${{ secrets.DOCKER_USERNAME }} @@ -123,7 +123,7 @@ jobs: steps: - 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,7 +134,7 @@ 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) @@ -164,7 +164,7 @@ jobs: - 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 }} From 1876886c801a75a477105e0d8f5b89cc210bfdb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:32:52 -0300 Subject: [PATCH 29/40] chore(deps): bump the actions-core group with 5 updates (#112) Bumps the actions-core group with 5 updates: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `4` | `6` | | [actions/setup-node](https://github.com/actions/setup-node) | `4` | `6` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` | | [actions/setup-go](https://github.com/actions/setup-go) | `5` | `6` | Updates `actions/checkout` from 4 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) Updates `actions/upload-artifact` from 4 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) Updates `actions/download-artifact` from 4 to 8 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v8) Updates `actions/setup-go` from 5 to 6 - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-core - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-core - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-core - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-core - dependency-name: actions/setup-go dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions-core ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/api-dog-e2e-tests.yml | 2 +- .github/workflows/branch-cleanup.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/dispatch-helm.yml | 2 +- .github/workflows/frontend-pr-analysis.yml | 14 +++++++------- .github/workflows/gitops-update.yml | 4 ++-- .github/workflows/go-ci.yml | 6 +++--- .github/workflows/go-pr-analysis.yml | 6 +++--- .github/workflows/go-security.yml | 4 ++-- .github/workflows/gptchangelog.yml | 6 +++--- .github/workflows/helm-update-chart.yml | 4 ++-- .github/workflows/release-notification.yml | 2 +- .github/workflows/typescript-ci.yml | 20 ++++++++++---------- 13 files changed, 37 insertions(+), 37 deletions(-) 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 240c4c6d..b83ecae6 100644 --- a/.github/workflows/branch-cleanup.yml +++ b/.github/workflows/branch-cleanup.yml @@ -48,7 +48,7 @@ jobs: 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 373ffc46..b7c2b8ce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -289,7 +289,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/dispatch-helm.yml b/.github/workflows/dispatch-helm.yml index 7d7e8caf..f48c94a9 100644 --- a/.github/workflows/dispatch-helm.yml +++ b/.github/workflows/dispatch-helm.yml @@ -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 06fe483d..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 @@ -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 c43004fa..9b72289c 100644 --- a/.github/workflows/gitops-update.yml +++ b/.github/workflows/gitops-update.yml @@ -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 diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index bfd871ac..577ade85 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/ diff --git a/.github/workflows/go-pr-analysis.yml b/.github/workflows/go-pr-analysis.yml index fd97aca7..2bb9bed4 100644 --- a/.github/workflows/go-pr-analysis.yml +++ b/.github/workflows/go-pr-analysis.yml @@ -460,7 +460,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 +496,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 +605,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: | diff --git a/.github/workflows/go-security.yml b/.github/workflows/go-security.yml index e7fd5619..79288dce 100644 --- a/.github/workflows/go-security.yml +++ b/.github/workflows/go-security.yml @@ -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 b309bfb2..be9ef955 100644 --- a/.github/workflows/gptchangelog.yml +++ b/.github/workflows/gptchangelog.yml @@ -57,7 +57,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 @@ -134,7 +134,7 @@ 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 @@ -252,7 +252,7 @@ jobs: 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 }} diff --git a/.github/workflows/helm-update-chart.yml b/.github/workflows/helm-update-chart.yml index 3bbc3ce6..dbc3e97e 100644 --- a/.github/workflows/helm-update-chart.yml +++ b/.github/workflows/helm-update-chart.yml @@ -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 }} @@ -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/release-notification.yml b/.github/workflows/release-notification.yml index 4a9f3f90..dcda48d3 100644 --- a/.github/workflows/release-notification.yml +++ b/.github/workflows/release-notification.yml @@ -121,7 +121,7 @@ jobs: private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Fetch latest release tag id: release diff --git a/.github/workflows/typescript-ci.yml b/.github/workflows/typescript-ci.yml index 61784110..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 }} @@ -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/ From 8ab1bb09e33b27f36605b3aae7c26298309e53bb Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:04:02 -0300 Subject: [PATCH 30/40] fix(ci): use path segment boundary matching to prevent prefix collisions (#135) * fix(ci): use path segment boundary matching to prevent prefix collisions * fix(ci): strip trailing slash from filter paths before matching * fix(ci): strip all trailing slashes from filter paths --- src/config/changed-paths/action.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/config/changed-paths/action.yml b/src/config/changed-paths/action.yml index 3143e327..89036637 100644 --- a/src/config/changed-paths/action.yml +++ b/src/config/changed-paths/action.yml @@ -128,7 +128,10 @@ runs: while read -r DIR; do while read -r FILTER; do [[ -z "$FILTER" ]] && continue - if [[ "$DIR" == "$FILTER"* ]]; then + while [[ "$FILTER" == */ && "$FILTER" != "/" ]]; do + FILTER="${FILTER%/}" + done + if [[ "$DIR" == "$FILTER" ]] || [[ "$DIR" == "$FILTER"/* ]]; then if [[ "$NORMALIZE_TO_FILTER" == "true" ]]; then FILTERED+="$FILTER"$'\n' else From 6d8d804e5106fde01ff2288b31d49e521378648a Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:03:01 -0300 Subject: [PATCH 31/40] refactor(ci): migrate basic workflows to internal changed-paths composite (#137) * refactor(ci): migrate basic workflows to internal changed-paths composite Replace external github-actions-changed-paths@main with the internal composite action (src/config/changed-paths) in gptchangelog, pr-security-scan, and typescript-release workflows. * refactor(ci): migrate build workflows and support newline filter_paths Migrate build and typescript-build to the internal changed-paths composite. Update composite to accept both JSON array and newline-separated formats for filter_paths input, ensuring backward compatibility with all callers. * fix(changed-paths): fail on malformed JSON filter_paths instead of silent fallback Detect if filter_paths starts with '[' and validate strictly with jq -er. Malformed JSON now fails with a clear error instead of silently producing an empty matrix. Also fix stale comment in build.yml. --- .github/workflows/build.yml | 4 ++-- .github/workflows/gptchangelog.yml | 2 +- .github/workflows/pr-security-scan.yml | 2 +- .github/workflows/typescript-build.yml | 2 +- .github/workflows/typescript-release.yml | 2 +- src/config/changed-paths/action.yml | 18 ++++++++++++++---- 6 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7c2b8ce..f99e125e 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 @@ -130,7 +130,7 @@ 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 }} diff --git a/.github/workflows/gptchangelog.yml b/.github/workflows/gptchangelog.yml index be9ef955..6f5c6e8b 100644 --- a/.github/workflows/gptchangelog.yml +++ b/.github/workflows/gptchangelog.yml @@ -141,7 +141,7 @@ jobs: - 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 }} diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index ceb83c05..8e238d86 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -69,7 +69,7 @@ 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 }} get_app_name: true diff --git a/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml index ff941290..1b837b48 100644 --- a/.github/workflows/typescript-build.yml +++ b/.github/workflows/typescript-build.yml @@ -153,7 +153,7 @@ jobs: - name: Get changed paths (monorepo) if: inputs.components_json == '' && 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 }} diff --git a/.github/workflows/typescript-release.yml b/.github/workflows/typescript-release.yml index 83cf456f..39623c21 100644 --- a/.github/workflows/typescript-release.yml +++ b/.github/workflows/typescript-release.yml @@ -64,7 +64,7 @@ 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 }} diff --git a/src/config/changed-paths/action.yml b/src/config/changed-paths/action.yml index 89036637..786ac37f 100644 --- a/src/config/changed-paths/action.yml +++ b/src/config/changed-paths/action.yml @@ -3,7 +3,7 @@ description: Detects changed files between commits and outputs a matrix of chang inputs: filter_paths: - description: 'JSON array of path prefixes to filter results (e.g., ["components/mdz", "components/transaction"])' + description: 'Newline-separated list of path prefixes to filter results (e.g., "components/mdz\ncomponents/transaction"). Also accepts JSON array format.' required: false default: '' path_level: @@ -118,10 +118,20 @@ runs: DIRS=$(echo "$DIRS" | cut -d'/' -f-"$PATH_LEVEL") fi - # Filter paths if filter_paths is provided (JSON array format) + # Filter paths if filter_paths is provided (supports both JSON array and newline-separated formats) if [[ -n "$FILTER_PATHS" ]] && [[ "$FILTER_PATHS" != "[]" ]] && [[ "$FILTER_PATHS" != "" ]]; then - # Parse JSON array into newline-separated values - FILTER_LIST=$(echo "$FILTER_PATHS" | jq -r '.[]' 2>/dev/null || echo "") + # 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="" From 896c92f4e56a9e12ff44a637a88cb85f6c583164 Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:18:39 -0300 Subject: [PATCH 32/40] feat(ci): add force_multiplatform input to build workflow (#93) * feat(ci): add force_multiplatform input to build workflow * fix: gate QEMU setup on arm64 platform presence instead of is_release --- .github/workflows/build.yml | 18 ++++++++++++++---- docs/build-workflow.md | 17 ++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f99e125e..12ab20ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,6 +113,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 @@ -164,9 +168,15 @@ 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 @@ -189,7 +199,7 @@ jobs: uses: actions/checkout@v6 - name: Set up QEMU - if: needs.prepare.outputs.is_release == 'true' + if: contains(needs.prepare.outputs.platforms, 'arm64') uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx 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 From fe4fa32ecaf4469d8741846595df2a3240876410 Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:58:48 -0300 Subject: [PATCH 33/40] chore(deps): update actions to latest major versions for Node 24 compatibility (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/checkout v4 → v6 (labels-sync composite) - docker/setup-qemu-action v3 → v4 - docker/setup-buildx-action v3 → v4 - docker/login-action v3 → v4 - docker/metadata-action v5 → v6 - docker/build-push-action v6 → v7 - gaurav-nelson/github-action-markdown-link-check → tcort/markdown-link-check-action (deprecated) --- .github/dependabot.yml | 2 +- .github/workflows/go-ci.yml | 2 +- src/build/docker-build-ts/action.yml | 14 +++++++------- src/config/labels-sync/action.yml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 24925fe6..2d7eb1e6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -99,7 +99,7 @@ updates: patterns: - "amannn/action-semantic-pull-request" - "actions/labeler" - - "gaurav-nelson/github-action-markdown-link-check" + - "tcort/markdown-link-check-action" - "actions/github-script" - "mikefarah/yq" update-types: diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index 577ade85..eceaa925 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -265,7 +265,7 @@ jobs: test -f LICENSE - name: Validate Markdown links - uses: gaurav-nelson/github-action-markdown-link-check@v1 + uses: tcort/markdown-link-check-action@v1 with: use-quiet-mode: 'yes' config-file: '.github/markdown-link-check-config.json' diff --git a/src/build/docker-build-ts/action.yml b/src/build/docker-build-ts/action.yml index 7c585673..0d72d9f8 100644 --- a/src/build/docker-build-ts/action.yml +++ b/src/build/docker-build-ts/action.yml @@ -85,21 +85,21 @@ runs: steps: - name: Set up QEMU if: ${{ contains(inputs.platforms, 'arm64') }} - uses: docker/setup-qemu-action@v3 + 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 == 'true' }} - uses: docker/login-action@v3 + 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@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -139,7 +139,7 @@ runs: - name: Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ steps.image-names.outputs.images }} tags: | @@ -196,7 +196,7 @@ runs: - name: Build Docker image (dry run) if: ${{ inputs.dry-run == 'true' }} id: build-dry - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ${{ inputs.build-context }} file: ${{ steps.dockerfile.outputs.path }} @@ -210,7 +210,7 @@ runs: - name: Build and push Docker image if: ${{ inputs.dry-run != 'true' }} id: build-push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ${{ inputs.build-context }} file: ${{ steps.dockerfile.outputs.path }} 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 From 0c7bd5b0595ceec652232a079110a28b54866150 Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:08:49 -0300 Subject: [PATCH 34/40] feat(ci): add integration tests and test determinism jobs to go-pr-analysis (#139) * feat(ci): add integration tests and test determinism jobs to go-pr-analysis Add optional jobs for integration testing and test determinism verification, enabling callers to consolidate CI workflows. New inputs: - enable_integration_tests (boolean, default false) - integration_test_command (string, default 'make test-integration') - enable_test_determinism (boolean, default false) - test_determinism_runs (number, default 3) * fix(ci): align test-determinism with tests job package exclusions Detect Makefile test target and exclude /tests/ and /api/ packages to match the same suite executed by the tests job. * fix(deps): correct tcort markdown-link-check action name * Revert "fix(deps): correct tcort markdown-link-check action name" This reverts commit 6d2281094dbe82c7701d3c9e1d920cab94919fdd. * fix(ci): always use go test with shuffle flags for determinism job Remove Makefile detection from test-determinism job. The tests job already validates that tests pass via Makefile; this job must always use go test with -count=1 -shuffle=on to actually verify determinism. * docs(ci): document integration tests and test determinism inputs --- .github/workflows/go-pr-analysis.yml | 106 ++++++++++++++++++++++++++- docs/go-pr-analysis-workflow.md | 14 +++- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/.github/workflows/go-pr-analysis.yml b/.github/workflows/go-pr-analysis.yml index 2bb9bed4..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 @@ -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/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 From ae466e08b48238f76cd7d3d31401a09ae9410caf Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:10:03 -0300 Subject: [PATCH 35/40] fix(deps): correct tcort markdown-link-check action repository name (#140) --- .github/dependabot.yml | 2 +- .github/workflows/go-ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2d7eb1e6..0819154e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -99,7 +99,7 @@ updates: patterns: - "amannn/action-semantic-pull-request" - "actions/labeler" - - "tcort/markdown-link-check-action" + - "tcort/github-action-markdown-link-check" - "actions/github-script" - "mikefarah/yq" update-types: diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index eceaa925..1ba3ad27 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -265,7 +265,7 @@ jobs: test -f LICENSE - name: Validate Markdown links - uses: tcort/markdown-link-check-action@v1 + uses: tcort/github-action-markdown-link-check@v1 with: use-quiet-mode: 'yes' config-file: '.github/markdown-link-check-config.json' From a36d49dddbeef04d8509f80430b0ad5b4bbf0326 Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:30:18 -0300 Subject: [PATCH 36/40] feat(security): add Docker Scout scan composite and integrate into pr-security-scan workflow (#142) * feat(security): add Docker Scout scan composite and integrate into pr-security-scan workflow * feat(security): add pr-security-reporter composite for PR comment orchestration --- .claude/commands/composite.md | 79 ++++++ .claude/commands/gha.md | 122 ++++++++- .claude/commands/workflow.md | 99 ++++++- .cursor/rules/composite-actions.mdc | 113 ++++++++ .cursor/rules/reusable-workflows.mdc | 104 ++++++++ .github/workflows/branch-cleanup.yml | 2 +- .github/workflows/labels-sync.yml | 2 +- .github/workflows/pr-security-scan.yml | 206 +++------------ docs/pr-security-scan-workflow.md | 29 +++ src/build/docker-build-ts/action.yml | 7 +- src/config/branch-cleanup/action.yml | 2 + src/notify/discord-release/action.yml | 3 + src/notify/slack-release/action.yml | 2 + src/security/docker-scout/README.md | 83 ++++++ src/security/docker-scout/action.yml | 120 +++++++++ src/security/pr-security-reporter/README.md | 85 +++++++ src/security/pr-security-reporter/action.yml | 255 +++++++++++++++++++ 17 files changed, 1128 insertions(+), 185 deletions(-) create mode 100644 src/security/docker-scout/README.md create mode 100644 src/security/docker-scout/action.yml create mode 100644 src/security/pr-security-reporter/README.md create mode 100644 src/security/pr-security-reporter/action.yml diff --git a/.claude/commands/composite.md b/.claude/commands/composite.md index efaa9607..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 @@ -176,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 f8ec1961..95d82653 100644 --- a/.claude/commands/gha.md +++ b/.claude/commands/gha.md @@ -253,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 | @@ -284,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 @@ -375,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 @@ -512,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 8a8cce56..cd073df4 100644 --- a/.claude/commands/workflow.md +++ b/.claude/commands/workflow.md @@ -144,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: @@ -177,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 @@ -268,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 @@ -282,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 e2db6e75..df5c3f14 100644 --- a/.cursor/rules/composite-actions.mdc +++ b/.cursor/rules/composite-actions.mdc @@ -85,6 +85,72 @@ 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. @@ -98,6 +164,33 @@ outputs: 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 @@ -203,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 5021af2f..878787da 100644 --- a/.cursor/rules/reusable-workflows.mdc +++ b/.cursor/rules/reusable-workflows.mdc @@ -195,6 +195,55 @@ 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 +## 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. @@ -222,6 +271,31 @@ jobs: 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). @@ -339,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/workflows/branch-cleanup.yml b/.github/workflows/branch-cleanup.yml index b83ecae6..25b20639 100644 --- a/.github/workflows/branch-cleanup.yml +++ b/.github/workflows/branch-cleanup.yml @@ -51,7 +51,7 @@ jobs: 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/labels-sync.yml b/.github/workflows/labels-sync.yml index 0dde64ba..0b072e89 100644 --- a/.github/workflows/labels-sync.yml +++ b/.github/workflows/labels-sync.yml @@ -44,7 +44,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Sync labels - uses: ./src/config/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 8e238d86..d7ad858e 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -46,6 +46,14 @@ 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_docker_scout: + description: 'Enable Docker Scout image analysis for vulnerability scoring (requires Docker Hub with Scout access)' + type: boolean + default: false + enable_docker_scout_recommendations: + description: 'Enable Docker Scout recommendations to surface Dockerfile issues (non-root user, missing attestations, base image gaps)' + type: boolean + default: true permissions: id-token: write # Required for OIDC authentication @@ -59,6 +67,7 @@ jobs: outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: + # ----------------- Setup ----------------- - name: Login to Docker Registry uses: docker/login-action@v4 with: @@ -66,6 +75,7 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} + # ----------------- Detect Changes ----------------- - name: Get changed paths (monorepo) if: inputs.filter_paths != '' id: changed-paths @@ -75,6 +85,7 @@ jobs: get_app_name: true path_level: ${{ inputs.path_level }} + # ----------------- Build Matrix ----------------- - name: Set matrix id: set-matrix run: | @@ -122,6 +133,7 @@ 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@v4 with: @@ -136,7 +148,7 @@ jobs: if: inputs.enable_docker_scan uses: docker/setup-buildx-action@v4 - # ----------------- Security Scans ----------------- + # ----------------- Secret Scanning ----------------- - name: Trivy Secret Scan - Component (Table Output) uses: aquasecurity/trivy-action@0.35.0 if: always() @@ -162,6 +174,7 @@ jobs: skip-dirs: '.git,node_modules,dist,build,.next,coverage,vendor' version: 'v0.69.2' + # ----------------- Docker Build ----------------- - name: Build Docker Image for Scanning if: always() && inputs.enable_docker_scan uses: docker/build-push-action@v7 @@ -176,6 +189,7 @@ 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) || '' }} + # ----------------- Vulnerability Scanning ----------------- - name: Trivy Vulnerability Scan - Docker Image (Table Output) if: always() && inputs.enable_docker_scan uses: aquasecurity/trivy-action@0.35.0 @@ -201,6 +215,18 @@ jobs: exit-code: '0' # Do not fail; gate failures in the table step version: 'v0.69.2' + # ----------------- Docker Scout Analysis ----------------- + - name: Docker Scout Analysis + id: docker-scout + if: always() && inputs.enable_docker_scout && inputs.enable_docker_scan + uses: LerianStudio/github-actions-shared-workflows/src/security/docker-scout@develop + with: + image: ${{ env.DOCKERHUB_ORG }}/${{ env.APP_NAME }}:pr-scan-${{ github.sha }} + dockerhub-user: ${{ secrets.DOCKER_USERNAME }} + dockerhub-password: ${{ secrets.DOCKER_PASSWORD }} + only-severities: critical,high,medium,low + enable-recommendations: ${{ inputs.enable_docker_scout_recommendations }} + # ----------------- Filesystem Vulnerability Scan ----------------- - name: Trivy Vulnerability Scan - Filesystem (JSON Output) id: fs-vuln-scan @@ -216,176 +242,20 @@ jobs: 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@v8 + 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}`); - } + github-token: ${{ secrets.GITHUB_TOKEN }} + app-name: ${{ env.APP_NAME }} + enable-docker-scan: ${{ inputs.enable_docker_scan }} + enable-docker-scout: ${{ inputs.enable_docker_scout }} + scout-quickview: ${{ steps.docker-scout.outputs.quickview }} + scout-cves: ${{ steps.docker-scout.outputs.cves }} + scout-has-vulnerabilities: ${{ steps.docker-scout.outputs.has-vulnerabilities }} + scout-recommendations: ${{ steps.docker-scout.outputs.recommendations }} - name: Gate - Fail on Security Findings if: always() && github.event_name == 'pull_request' @@ -413,7 +283,7 @@ jobs: # with: # sarif_file: 'trivy-vulnerability-scan-docker-${{ env.APP_NAME }}.sarif' - # Slack notification + # ----------------- Slack Notification ----------------- notify: name: Notify needs: [prepare_matrix, security_scan] 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/src/build/docker-build-ts/action.yml b/src/build/docker-build-ts/action.yml index 0d72d9f8..0f1cd0ce 100644 --- a/src/build/docker-build-ts/action.yml +++ b/src/build/docker-build-ts/action.yml @@ -83,6 +83,7 @@ outputs: runs: using: composite steps: + # ----------------- Setup ----------------- - name: Set up QEMU if: ${{ contains(inputs.platforms, 'arm64') }} uses: docker/setup-qemu-action@v4 @@ -105,6 +106,7 @@ runs: username: ${{ github.actor }} password: ${{ inputs.ghcr-token }} + # ----------------- Image Configuration ----------------- - name: Normalize repository owner to lowercase id: normalize shell: bash @@ -179,7 +181,7 @@ runs: } >> $GITHUB_OUTPUT fi - # dry_run: true — verbose summary, build without pushing + # ----------------- Dry Run ----------------- - name: Dry run summary if: ${{ inputs.dry-run == 'true' }} shell: bash @@ -192,7 +194,6 @@ runs: echo " tags : ${{ steps.meta.outputs.tags }}" echo " registries : ${{ steps.image-names.outputs.images }}" - # Dry-run uses only linux/amd64 for fast validation (QEMU multi-platform is slow) - name: Build Docker image (dry run) if: ${{ inputs.dry-run == 'true' }} id: build-dry @@ -206,7 +207,7 @@ runs: labels: ${{ steps.meta.outputs.labels }} secrets: ${{ steps.secrets.outputs.value }} - # dry_run: false — build and push + # ----------------- Build & Push ----------------- - name: Build and push Docker image if: ${{ inputs.dry-run != 'true' }} id: build-push 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/notify/discord-release/action.yml b/src/notify/discord-release/action.yml index 8a3af63c..60fd0002 100644 --- a/src/notify/discord-release/action.yml +++ b/src/notify/discord-release/action.yml @@ -36,6 +36,7 @@ inputs: runs: using: composite steps: + # ----------------- Pre-flight Check ----------------- - name: Detect beta release id: beta shell: bash @@ -46,6 +47,7 @@ runs: echo "is_beta=false" >> $GITHUB_OUTPUT fi + # ----------------- Dry Run ----------------- - name: Dry run summary if: inputs.dry-run == 'true' shell: bash @@ -59,6 +61,7 @@ runs: echo " skip-beta : ${{ inputs.skip-beta }}" echo " is_beta : ${{ steps.beta.outputs.is_beta }}" + # ----------------- Notification ----------------- - name: Send Discord notification if: >- inputs.dry-run != 'true' diff --git a/src/notify/slack-release/action.yml b/src/notify/slack-release/action.yml index 6739b287..5cade456 100644 --- a/src/notify/slack-release/action.yml +++ b/src/notify/slack-release/action.yml @@ -30,6 +30,7 @@ inputs: runs: using: composite steps: + # ----------------- Dry Run ----------------- - name: Dry run summary if: inputs.dry-run == 'true' shell: bash @@ -41,6 +42,7 @@ runs: 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 diff --git a/src/security/docker-scout/README.md b/src/security/docker-scout/README.md new file mode 100644 index 00000000..c3222c37 --- /dev/null +++ b/src/security/docker-scout/README.md @@ -0,0 +1,83 @@ + + + + + +
Lerian

docker-scout

+ +Composite action that runs [Docker Scout](https://docs.docker.com/scout/) analysis on a locally built Docker image, producing a quickview summary and detailed CVE report. Optionally exports results in SARIF format. + +Uses [`docker/scout-action@v1.20.2`](https://github.com/docker/scout-action) — chosen for being the official Docker Scout integration maintained by Docker Inc. + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `image` | Image reference to scan (local tag) | Yes | — | +| `dockerhub-user` | DockerHub username for Scout authentication | Yes | — | +| `dockerhub-password` | DockerHub password for Scout authentication | Yes | — | +| `github-token` | GitHub token | No | `${{ github.token }}` | +| `only-severities` | Severities to include (csv) | No | `critical,high,medium,low` | +| `exit-code` | Fail the step if vulnerabilities are found | No | `false` | +| `write-comment` | Post results as a PR comment (via Scout) | No | `false` | +| `sarif-file` | Path to export SARIF file (empty = skip) | No | `""` | +| `enable-recommendations` | Run Scout recommendations (non-root user, attestation gaps, base image issues) | No | `true` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `quickview` | Markdown summary from Scout quickview | +| `cves` | Markdown CVE details from Scout cves | +| `has-vulnerabilities` | `true` if vulnerabilities were found | +| `recommendations` | Raw recommendations output (Dockerfile issues, attestation gaps, non-root user) | + +## Usage as composite step + +```yaml +jobs: + scan: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + uses: docker/build-push-action@v7 + with: + load: true + push: false + tags: myorg/myapp:scan + + - name: Docker Scout Analysis + id: scout + uses: ./src/security/docker-scout + with: + image: myorg/myapp:scan + dockerhub-user: ${{ secrets.DOCKER_USERNAME }} + dockerhub-password: ${{ secrets.DOCKER_PASSWORD }} + only-severities: critical,high +``` + +## Usage via reusable workflow + +```yaml +jobs: + security-scan: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-security-scan.yml@v1.0.0 + with: + enable_docker_scout: true + secrets: inherit +``` + +## Required permissions + +```yaml +permissions: + contents: read + pull-requests: write # only if write-comment is true +``` + +## Prerequisites + +Docker Scout requires a Docker Hub account with Scout access. Ensure the `DOCKER_USERNAME` and `DOCKER_PASSWORD` secrets correspond to an account with an active Docker Scout subscription (Free, Team, or Business). diff --git a/src/security/docker-scout/action.yml b/src/security/docker-scout/action.yml new file mode 100644 index 00000000..e23f22d3 --- /dev/null +++ b/src/security/docker-scout/action.yml @@ -0,0 +1,120 @@ +name: Docker Scout Analysis +description: Run Docker Scout quickview and CVE analysis on a locally built image. + +inputs: + image: + description: Image reference to scan (local tag) + required: true + dockerhub-user: + description: DockerHub username for Scout authentication + required: true + dockerhub-password: + description: DockerHub password for Scout authentication + required: true + github-token: + description: GitHub token + required: false + default: ${{ github.token }} + only-severities: + description: Severities to include (csv) + required: false + default: "critical,high,medium,low" + exit-code: + description: Fail the step if vulnerabilities are found + required: false + default: "false" + write-comment: + description: Post results as a PR comment (via Scout) + required: false + default: "false" + sarif-file: + description: Path to export SARIF file (empty = skip SARIF export) + required: false + default: "" + enable-recommendations: + description: Run Docker Scout recommendations to surface Dockerfile issues (non-root user, base image gaps, attestation status) + required: false + default: "true" + +outputs: + quickview: + description: Markdown summary from Scout quickview + value: ${{ steps.quickview.outputs.quickview }} + cves: + description: Markdown CVE details from Scout cves + value: ${{ steps.cves.outputs.cves }} + has-vulnerabilities: + description: "'true' if vulnerabilities were found" + value: ${{ steps.parse.outputs.has_vulnerabilities }} + recommendations: + description: Recommendations output from Scout (Dockerfile issues, missing attestations, non-root user) + value: ${{ steps.recommendations.outputs.recommendations }} + +runs: + using: composite + steps: + # ----------------- Vulnerability Analysis ----------------- + - name: Docker Scout Quickview + id: quickview + uses: docker/scout-action@v1.20.2 + with: + command: quickview + image: ${{ inputs.image }} + dockerhub-user: ${{ inputs.dockerhub-user }} + dockerhub-password: ${{ inputs.dockerhub-password }} + github-token: ${{ inputs.github-token }} + only-severities: ${{ inputs.only-severities }} + write-comment: ${{ inputs.write-comment }} + + - name: Docker Scout CVEs + id: cves + uses: docker/scout-action@v1.20.2 + with: + command: cves + image: ${{ inputs.image }} + dockerhub-user: ${{ inputs.dockerhub-user }} + dockerhub-password: ${{ inputs.dockerhub-password }} + github-token: ${{ inputs.github-token }} + only-severities: ${{ inputs.only-severities }} + exit-code: ${{ inputs.exit-code }} + write-comment: ${{ inputs.write-comment }} + + # ----------------- SARIF Export ----------------- + - name: Docker Scout SARIF Export + if: ${{ inputs.sarif-file != '' }} + uses: docker/scout-action@v1.20.2 + with: + command: cves + image: ${{ inputs.image }} + dockerhub-user: ${{ inputs.dockerhub-user }} + dockerhub-password: ${{ inputs.dockerhub-password }} + github-token: ${{ inputs.github-token }} + only-severities: ${{ inputs.only-severities }} + sarif-file: ${{ inputs.sarif-file }} + + # ----------------- Recommendations ----------------- + - name: Docker Scout Recommendations + id: recommendations + if: ${{ inputs.enable-recommendations == 'true' }} + uses: docker/scout-action@v1.20.2 + with: + command: recommendations + image: ${{ inputs.image }} + dockerhub-user: ${{ inputs.dockerhub-user }} + dockerhub-password: ${{ inputs.dockerhub-password }} + github-token: ${{ inputs.github-token }} + + # ----------------- Result Parsing ----------------- + - name: Parse results + id: parse + shell: bash + run: | + QUICKVIEW_OUTPUT='${{ steps.quickview.outputs.quickview }}' + CVES_OUTPUT='${{ steps.cves.outputs.cves }}' + + HAS_VULNS="false" + if echo "$QUICKVIEW_OUTPUT" "$CVES_OUTPUT" | grep -qiE '(critical|high|medium|low)\s+[1-9]'; then + HAS_VULNS="true" + fi + + echo "has_vulnerabilities=$HAS_VULNS" >> "$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..fea6dbdc --- /dev/null +++ b/src/security/pr-security-reporter/README.md @@ -0,0 +1,85 @@ + + + + + +
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 Scout results. 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-docker-scout` | Whether Docker Scout results are present and should be included | No | `false` | +| `scout-quickview` | Docker Scout quickview output | No | `""` | +| `scout-cves` | Docker Scout CVE list output | No | `""` | +| `scout-has-vulnerabilities` | Whether Docker Scout detected vulnerabilities | 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) | + +## Usage + +### As a composite step (within a security workflow job) + +```yaml +# Use @develop or your feature branch to test before releasing +- 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-docker-scout: ${{ inputs.enable_docker_scout }} + scout-quickview: ${{ steps.docker-scout.outputs.quickview }} + scout-cves: ${{ steps.docker-scout.outputs.cves }} + scout-has-vulnerabilities: ${{ steps.docker-scout.outputs.has-vulnerabilities }} +``` + +### 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 +``` + +### Gate on findings + +```yaml +- name: Gate - Fail on Security Findings + 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..ad7f9920 --- /dev/null +++ b/src/security/pr-security-reporter/action.yml @@ -0,0 +1,255 @@ +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-docker-scout: + description: Whether Docker Scout analysis results are present and should be included in the report + required: false + default: "false" + scout-quickview: + description: Docker Scout quickview output (from docker-scout composite outputs.quickview) + required: false + default: "" + scout-cves: + description: Docker Scout CVE list output (from docker-scout composite outputs.cves) + required: false + default: "" + scout-has-vulnerabilities: + description: Whether Docker Scout detected vulnerabilities (from docker-scout composite outputs.has-vulnerabilities) + required: false + default: "false" + scout-recommendations: + description: Docker Scout recommendations output (Dockerfile issues, missing attestations, non-root user) + required: false + default: "" + +outputs: + has-findings: + description: True if any security vulnerabilities were found + value: ${{ steps.report.outputs.has_findings }} + has-errors: + description: True if any scan artifacts were missing or could not be parsed + value: ${{ steps.report.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_DOCKER_SCOUT: ${{ inputs.enable-docker-scout }} + SCOUT_QUICKVIEW: ${{ inputs.scout-quickview }} + SCOUT_CVES: ${{ inputs.scout-cves }} + SCOUT_HAS_VULNS: ${{ inputs.scout-has-vulnerabilities }} + SCOUT_RECOMMENDATIONS: ${{ inputs.scout-recommendations }} + with: + github-token: ${{ inputs.github-token }} + script: | + const fs = require('fs'); + const appName = process.env.APP_NAME; + const dockerScanEnabled = process.env.ENABLE_DOCKER_SCAN === 'true'; + const dockerScoutEnabled = process.env.ENABLE_DOCKER_SCOUT === 'true'; + + 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`; + } + } + + // Docker Scout Analysis section + if (dockerScoutEnabled && dockerScanEnabled) { + const scoutQuickview = process.env.SCOUT_QUICKVIEW || ''; + const scoutCves = process.env.SCOUT_CVES || ''; + const scoutHasVulns = process.env.SCOUT_HAS_VULNS || 'false'; + + body += `### Docker Scout Analysis\n\n`; + if (scoutQuickview) { + body += `
\nQuickview\n\n\`\`\`\n${scoutQuickview}\n\`\`\`\n\n
\n\n`; + } + if (scoutCves) { + body += `
\nCVE Details\n\n\`\`\`\n${scoutCves}\n\`\`\`\n\n
\n\n`; + } + if (scoutHasVulns === 'true') { + hasFindings = true; + body += `> **Warning**: Docker Scout detected vulnerabilities.\n\n`; + } else if (!scoutQuickview && !scoutCves) { + hasScanErrors = true; + body += `⚠️ Docker Scout results not available.\n\n`; + } else { + body += `✅ No vulnerabilities detected by Docker Scout.\n\n`; + } + } + + // Docker Scout Recommendations section + const scoutRecommendations = process.env.SCOUT_RECOMMENDATIONS || ''; + if (scoutRecommendations) { + body += `### Docker Scout Recommendations\n\n`; + body += `> These are informational — they do not block the build.\n\n`; + body += `
\nView recommendations\n\n\`\`\`\n${scoutRecommendations}\n\`\`\`\n\n
\n\n`; + } + + if (!hasFindings && !hasScanErrors) { + body += `\n✅ **All security checks passed.**\n`; + } + + 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}`); + } From 0af0229a0e0c5cb8ffcf481f1b95c6e08adaa053 Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:02:45 -0300 Subject: [PATCH 37/40] feat(security): Docker Scout integration with policy enforcement and supply chain attestations (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): remove code fences from Scout HTML outputs in pr-security-reporter * fix(ci): point composite refs to fix branch for testing * fix(security): add divider between Trivy and Docker Scout sections in PR comment * fix(security): add section headers and divider for Trivy and Docker Scout in PR comment * fix(security): disable auto PR comment on Scout recommendations to avoid duplicates * feat(security): add Scout compare with environments and environment recording in build workflow * feat(security): fallback docker_scout_compare_env to github.base_ref when not provided * docs(security): fix docker_scout_compare_env input description to reflect fallback behavior * fix(config): suppress broken pipe error in changed-paths tag comparison * fix(ci): temporarily point changed-paths ref to fix branch for broken pipe fix * fix(security): pass organization to all Scout steps and graceful compare fallback Pass organization input to quickview, cves, and recommendations steps to fix "no organization configured" warning. Add continue-on-error to compare step so missing environment baselines don't fail the workflow. * fix(ci): use metadata-action version for Scout environment image tag The version step outputs v1.5.0-beta.6 (with v prefix) but docker/metadata-action strips it to 1.5.0-beta.6. Scout environment was using the wrong tag, causing MANIFEST_UNKNOWN on pull. * feat(security): add app_name_prefix and app_name_overrides inputs to pr-security-scan Aligns component names between build and security scan workflows so Docker Scout compare can find the correct image in the environment. * fix(security): hide Scout recommendations when no actionable findings Skip rendering the recommendations section when Scout reports "image version is up to date" and "no tag recommendations", avoiding empty/unhelpful output in PR comments. * fix(security): show positive message when no Scout recommendations found * fix(security): show positive message when no CVEs found by Scout * feat(security): improve Scout PR comment with policy grades and visual cues - Rename Quickview to Overview & Policies with derived letter grade (A-F) from policy pass ratio so devs know at a glance if they need to expand - Add alert emoji on CVE Details when vulnerabilities are found - Add lightbulb emoji on recommendations when actionable suggestions exist - Scout CVEs now set hasFindings=true to fail the security gate * fix(security): replace derived grade with policy pass/fail status Custom A-F grade didn't match Docker Hub's actual scoring. Show policy count and PASSED/FAILED status instead. * fix(security): reorder Scout sections — compare after overview * fix(security): remove redundant Scout success message * fix(security): remove redundant final success message from PR comment * refactor(security): restructure pr-security-reporter into functions Extract Trivy FS scan, Docker image scan, and Scout analysis into separate functions for readability. Add shared helpers for severity sorting, markdown escaping, truncation, and details blocks. * style(security): improve PR comment visual hierarchy - Main title promoted to h1 with separator - Section headers (Trivy, Docker Scout) as h2 - Sub-sections (Filesystem Scan, Docker Image Scan, CVE Details, Recommendations) promoted to h3 - Added dividers between major sections * Revert "style(security): improve PR comment visual hierarchy" This reverts commit b396a662d866a44d0211d200b269f13d01847ae5. * feat(security): add docker_scout_fail_on_policy option to break on policy failures New opt-in input (default false) that fails the security gate when Docker Scout policies are not fully met (e.g., non-root user, missing attestations). Callers can enable it when ready to enforce policies. * feat(security): default docker_scout_fail_on_policy to true When Docker Scout is enabled, enforce policy compliance by default. Callers can opt out with docker_scout_fail_on_policy: false. * fix(security): pass Scout outputs via files instead of env vars Env vars truncate multiline HTML content from Docker Scout outputs. Write outputs to files in a .scout-outputs directory and read them in the reporter, fixing empty quickview/cves/recommendations. * fix(security): propagate reporter outputs via shell step core.setOutput in actions/github-script inside composite actions does not propagate to the composite outputs. Use the script return value (result) and parse it in a shell step that writes directly to GITHUB_OUTPUT. * fix(security): fix double-encoded JSON output and unreachable PR comment code The github-script default result-encoding (json) was double-encoding the returned JSON string, causing jq parse failure (exit code 5). Added result-encoding: string to fix. Also moved the return statement after the Post Comment block which was unreachable. * fix(security): use hyphenated output names from reporter composite The composite outputs are defined as has-findings/has-errors (hyphens) but the gate step was referencing has_findings/has_errors (underscores), resulting in empty values and the gate never failing. * fix(security): default docker_scout_fail_on_policy to false Disable policy failure gate by default so Scout findings are informational unless callers explicitly opt in. * feat(build): enable SBOM and provenance attestations on Docker builds Adds sbom: true and provenance: mode=max to docker/build-push-action to satisfy Docker Scout supply chain attestation policies. * feat(build): auto-enable Docker Scout for repo before environment recording Runs docker scout repo enable before recording the image in a Scout environment. The command is idempotent and the || true ensures it doesn't fail the build if the repo is already enabled. * fix(gitops): fix invalid GITHUB_OUTPUT format when no files updated When UPDATED_FILES was empty, grep -c returned 0 with exit code 1, triggering || echo 0 which appended a second 0 to the output line, producing an invalid format for GITHUB_OUTPUT. * fix(security): add continue-on-error to all Docker Scout steps Scout quickview fails with "image has no base image" on locally built images without provenance. All Scout steps should be non-blocking since the reporter handles missing outputs gracefully. * fix(security): show fallback message when Scout quickview is unavailable When the quickview step fails (e.g. base image not detected on local builds), display an informational message instead of silently omitting the Overview & Policies section. * fix(security): add if: always() to all Docker Scout composite steps When quickview fails (e.g. base image not detected), subsequent steps (cves, recommendations, compare) were skipped because composite actions stop on failure by default. Adding if: always() ensures all steps run independently. * fix(security): only show quickview fallback when compare is also unavailable When quickview fails but compare is active, policies are already shown in the compare section. The fallback warning is now only displayed when neither quickview nor compare have data. * fix(security): use generic fallback message for unavailable policy evaluation * feat(security): show policy status in compare section header When quickview is unavailable and compare is active, parse the compare output for policy status icons and display a summary (e.g. 5/7 policies met) in the collapsible header. Also enforce fail-on-policy from compare data when quickview is missing. * refactor(security): remove quickview in favor of compare for policy evaluation Quickview requires provenance attestations to detect the base image, which is not possible with load: true (local builds). The compare command already provides policy evaluation via the Scout backend. - Remove quickview step from docker-scout composite - Remove quickview output and file saving - Simplify reporter to use compare for policy status - Show informational message when no environment is configured * fix(security): parse policy count from PR column only in compare output The compare table has policy status for both environment and PR images. Counting all icons doubled the total. Now parses each row and reads only the PR column (second status) for accurate policy counts. * revert(build): remove explicit sbom and provenance attestations Docker Scout cannot read attestations from image indexes, making explicit sbom: true and provenance: mode=max ineffective. BuildKit default (provenance: mode=min) is kept implicitly. * feat(build): use Scout SBOM indexer for attestations Use docker/scout-sbom-indexer as the SBOM generator instead of the default BuildKit generator. This produces attestations in the format that Docker Scout expects for policy evaluation. * fix(build): install Scout CLI before repo enable command The docker scout CLI plugin is not available on the runner by default. Install it before running docker scout repo enable. * fix(build): install Scout CLI as Docker plugin instead of standalone binary The install script without -b flag installs to ~/.docker/cli-plugins/ by default, which is required for docker scout subcommand to work. * fix(security): fix policy row parsing in compare output The compare table format is |Name|env_status|pr_status|Change|Standing|. The regex was matching from the first column expecting a status icon, but the first column is the policy name. Fixed to match rows with status icons in the second and third columns, and read PR status from column index 3. * feat(security): show policy summary table outside collapsible Extract policy status from compare output and display as a visible table with pass/fail count header. The full compare details remain in a collapsible section below. * feat(security): render clean policy status text in summary table Replace raw markdown icons (:white_check_mark:, :warning:, :question:) with readable text (Passed, Failed, No data) in the policy summary. * feat(security): add environment tag and PR image columns to policy table Show both the remote environment version and the local PR image status side by side in the policy summary table for better comparison. * fix(security): remove redundant status column from policy table * fix(security): omit policies that can't be evaluated on local builds Skip supply chain attestations, outdated base images, and unapproved base images from the policy summary since they require provenance data not available on locally built images. * fix(security): always show recommendations section even when empty The recommendations step can fail silently (base image not detected), leaving the output empty. Show the positive message as fallback. * refactor(security): remove recommendations from PR scan Recommendations depends on base image detection which fails on locally built images (load: true). Remove the step, input, output, and reporter section to reduce noise. Recommendations will be available via the Scout dashboard for pushed images. * refactor(security): replace Docker Scout with internal health score checks * refactor(build): remove Scout environment recording and switch SBOM generator to syft * feat(security): add high-profile vulnerabilities check via CISA KEV catalog * fix(build): revert SBOM generator to docker/scout-sbom-indexer * feat(security): use org token for PR comments and add useful links * fix(security): keep only scan logs link in PR comment * chore(security): add emoji to scan link and bump trivy to v0.69.3 * fix(security): exclude USER root/0 from non-root user check and remove dead code * refactor(security): extract trivy scans and checks into reusable composites * fix(security): scope Trivy fs scanners explicitly and fix README output reference * chore: update all composite refs from fix/scout-html-output to develop --- .github/workflows/build.yml | 2 + .github/workflows/gitops-update.yml | 6 +- .github/workflows/pr-security-scan.yml | 192 +++------- src/config/changed-paths/README.md | 61 +++ src/config/changed-paths/action.yml | 75 +++- src/notify/slack-notify/README.md | 58 +++ src/notify/slack-notify/action.yml | 129 +++++++ src/security/docker-scout/README.md | 83 ---- src/security/docker-scout/action.yml | 120 ------ src/security/dockerfile-checks/README.md | 67 ++++ src/security/dockerfile-checks/action.yml | 43 +++ src/security/pr-security-reporter/README.md | 38 +- src/security/pr-security-reporter/action.yml | 376 ++++++++++++------- src/security/trivy-fs-scan/README.md | 79 ++++ src/security/trivy-fs-scan/action.yml | 85 +++++ src/security/trivy-image-scan/README.md | 81 ++++ src/security/trivy-image-scan/action.yml | 95 +++++ 17 files changed, 1094 insertions(+), 496 deletions(-) create mode 100644 src/notify/slack-notify/README.md create mode 100644 src/notify/slack-notify/action.yml delete mode 100644 src/security/docker-scout/README.md delete mode 100644 src/security/docker-scout/action.yml create mode 100644 src/security/dockerfile-checks/README.md create mode 100644 src/security/dockerfile-checks/action.yml create mode 100644 src/security/trivy-fs-scan/README.md create mode 100644 src/security/trivy-fs-scan/action.yml create mode 100644 src/security/trivy-image-scan/README.md create mode 100644 src/security/trivy-image-scan/action.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12ab20ac..ef36bd83 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -280,6 +280,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: | diff --git a/.github/workflows/gitops-update.yml b/.github/workflows/gitops-update.yml index 9b72289c..024a8ee8 100644 --- a/.github/workflows/gitops-update.yml +++ b/.github/workflows/gitops-update.yml @@ -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 diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index d7ad858e..79c50a3e 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -22,6 +22,18 @@ on: 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,12 +58,8 @@ 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_docker_scout: - description: 'Enable Docker Scout image analysis for vulnerability scoring (requires Docker Hub with Scout access)' - type: boolean - default: false - enable_docker_scout_recommendations: - description: 'Enable Docker Scout recommendations to surface Dockerfile issues (non-root user, missing attestations, base image gaps)' + enable_health_score: + description: 'Enable Docker Hub Health Score compliance checks (non-root user, CVEs, licenses)' type: boolean default: true @@ -65,7 +73,7 @@ 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 @@ -75,48 +83,23 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - # ----------------- Detect Changes ----------------- - - name: Get changed paths (monorepo) - if: inputs.filter_paths != '' + # ----------------- Detect Changes & Build Matrix ----------------- + - name: Get changed paths id: changed-paths 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 }} - - # ----------------- Build Matrix ----------------- - - 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 + 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 @@ -148,31 +131,14 @@ jobs: if: inputs.enable_docker_scan uses: docker/setup-buildx-action@v4 - # ----------------- Secret Scanning ----------------- - - name: Trivy Secret Scan - Component (Table Output) - uses: aquasecurity/trivy-action@0.35.0 + # ----------------- 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.35.0 - 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 @@ -189,58 +155,23 @@ 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) || '' }} - # ----------------- Vulnerability Scanning ----------------- - - 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.35.0 + 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.35.0 - 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' + app-name: ${{ env.APP_NAME }} + enable-license-scan: ${{ inputs.enable_health_score }} - # ----------------- Docker Scout Analysis ----------------- - - name: Docker Scout Analysis - id: docker-scout - if: always() && inputs.enable_docker_scout && inputs.enable_docker_scan - uses: LerianStudio/github-actions-shared-workflows/src/security/docker-scout@develop + # ----------------- 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: ${{ env.DOCKERHUB_ORG }}/${{ env.APP_NAME }}:pr-scan-${{ github.sha }} - dockerhub-user: ${{ secrets.DOCKER_USERNAME }} - dockerhub-password: ${{ secrets.DOCKER_PASSWORD }} - only-severities: critical,high,medium,low - enable-recommendations: ${{ inputs.enable_docker_scout_recommendations }} - - # ----------------- Filesystem Vulnerability Scan ----------------- - - name: Trivy Vulnerability Scan - Filesystem (JSON Output) - id: fs-vuln-scan - uses: aquasecurity/trivy-action@0.35.0 - 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' + dockerfile-path: ${{ env.DOCKERFILE_PATH }} # ----------------- Results & Security Gate ----------------- - name: Post Security Scan Results to PR @@ -248,25 +179,12 @@ jobs: 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 }} + github-token: ${{ secrets.MANAGE_TOKEN || secrets.GITHUB_TOKEN }} app-name: ${{ env.APP_NAME }} enable-docker-scan: ${{ inputs.enable_docker_scan }} - enable-docker-scout: ${{ inputs.enable_docker_scout }} - scout-quickview: ${{ steps.docker-scout.outputs.quickview }} - scout-cves: ${{ steps.docker-scout.outputs.cves }} - scout-has-vulnerabilities: ${{ steps.docker-scout.outputs.has-vulnerabilities }} - scout-recommendations: ${{ steps.docker-scout.outputs.recommendations }} - - - 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 + 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 @@ -288,10 +206,12 @@ jobs: 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/src/config/changed-paths/README.md b/src/config/changed-paths/README.md index ae8cf948..1886043f 100644 --- a/src/config/changed-paths/README.md +++ b/src/config/changed-paths/README.md @@ -17,6 +17,10 @@ Composite action that detects changed files between commits and outputs a matrix | `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 @@ -82,6 +86,63 @@ with: ] ``` +### 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 normalize_to_filter When `normalize_to_filter: true`, deeper changed paths are normalized back to the matching filter path. diff --git a/src/config/changed-paths/action.yml b/src/config/changed-paths/action.yml index 786ac37f..63a26f1a 100644 --- a/src/config/changed-paths/action.yml +++ b/src/config/changed-paths/action.yml @@ -26,6 +26,22 @@ inputs: 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: @@ -55,7 +71,7 @@ runs: CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "") if [[ -n "$CURRENT_TAG" ]]; then # Compare against previous tag to capture all changes since last release - PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -1) + PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -1 || true) if [[ -n "$PREV_TAG" ]]; then echo "Tag push detected: comparing $PREV_TAG..$CURRENT_TAG" FILES=$(git diff --name-only "$PREV_TAG" HEAD) @@ -90,6 +106,19 @@ runs: 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 @@ -167,6 +196,25 @@ runs: # 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." @@ -216,6 +264,31 @@ runs: 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/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/security/docker-scout/README.md b/src/security/docker-scout/README.md deleted file mode 100644 index c3222c37..00000000 --- a/src/security/docker-scout/README.md +++ /dev/null @@ -1,83 +0,0 @@ - - - - - -
Lerian

docker-scout

- -Composite action that runs [Docker Scout](https://docs.docker.com/scout/) analysis on a locally built Docker image, producing a quickview summary and detailed CVE report. Optionally exports results in SARIF format. - -Uses [`docker/scout-action@v1.20.2`](https://github.com/docker/scout-action) — chosen for being the official Docker Scout integration maintained by Docker Inc. - -## Inputs - -| Input | Description | Required | Default | -|-------|-------------|----------|---------| -| `image` | Image reference to scan (local tag) | Yes | — | -| `dockerhub-user` | DockerHub username for Scout authentication | Yes | — | -| `dockerhub-password` | DockerHub password for Scout authentication | Yes | — | -| `github-token` | GitHub token | No | `${{ github.token }}` | -| `only-severities` | Severities to include (csv) | No | `critical,high,medium,low` | -| `exit-code` | Fail the step if vulnerabilities are found | No | `false` | -| `write-comment` | Post results as a PR comment (via Scout) | No | `false` | -| `sarif-file` | Path to export SARIF file (empty = skip) | No | `""` | -| `enable-recommendations` | Run Scout recommendations (non-root user, attestation gaps, base image issues) | No | `true` | - -## Outputs - -| Output | Description | -|--------|-------------| -| `quickview` | Markdown summary from Scout quickview | -| `cves` | Markdown CVE details from Scout cves | -| `has-vulnerabilities` | `true` if vulnerabilities were found | -| `recommendations` | Raw recommendations output (Dockerfile issues, attestation gaps, non-root user) | - -## Usage as composite step - -```yaml -jobs: - scan: - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Build image - uses: docker/build-push-action@v7 - with: - load: true - push: false - tags: myorg/myapp:scan - - - name: Docker Scout Analysis - id: scout - uses: ./src/security/docker-scout - with: - image: myorg/myapp:scan - dockerhub-user: ${{ secrets.DOCKER_USERNAME }} - dockerhub-password: ${{ secrets.DOCKER_PASSWORD }} - only-severities: critical,high -``` - -## Usage via reusable workflow - -```yaml -jobs: - security-scan: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-security-scan.yml@v1.0.0 - with: - enable_docker_scout: true - secrets: inherit -``` - -## Required permissions - -```yaml -permissions: - contents: read - pull-requests: write # only if write-comment is true -``` - -## Prerequisites - -Docker Scout requires a Docker Hub account with Scout access. Ensure the `DOCKER_USERNAME` and `DOCKER_PASSWORD` secrets correspond to an account with an active Docker Scout subscription (Free, Team, or Business). diff --git a/src/security/docker-scout/action.yml b/src/security/docker-scout/action.yml deleted file mode 100644 index e23f22d3..00000000 --- a/src/security/docker-scout/action.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Docker Scout Analysis -description: Run Docker Scout quickview and CVE analysis on a locally built image. - -inputs: - image: - description: Image reference to scan (local tag) - required: true - dockerhub-user: - description: DockerHub username for Scout authentication - required: true - dockerhub-password: - description: DockerHub password for Scout authentication - required: true - github-token: - description: GitHub token - required: false - default: ${{ github.token }} - only-severities: - description: Severities to include (csv) - required: false - default: "critical,high,medium,low" - exit-code: - description: Fail the step if vulnerabilities are found - required: false - default: "false" - write-comment: - description: Post results as a PR comment (via Scout) - required: false - default: "false" - sarif-file: - description: Path to export SARIF file (empty = skip SARIF export) - required: false - default: "" - enable-recommendations: - description: Run Docker Scout recommendations to surface Dockerfile issues (non-root user, base image gaps, attestation status) - required: false - default: "true" - -outputs: - quickview: - description: Markdown summary from Scout quickview - value: ${{ steps.quickview.outputs.quickview }} - cves: - description: Markdown CVE details from Scout cves - value: ${{ steps.cves.outputs.cves }} - has-vulnerabilities: - description: "'true' if vulnerabilities were found" - value: ${{ steps.parse.outputs.has_vulnerabilities }} - recommendations: - description: Recommendations output from Scout (Dockerfile issues, missing attestations, non-root user) - value: ${{ steps.recommendations.outputs.recommendations }} - -runs: - using: composite - steps: - # ----------------- Vulnerability Analysis ----------------- - - name: Docker Scout Quickview - id: quickview - uses: docker/scout-action@v1.20.2 - with: - command: quickview - image: ${{ inputs.image }} - dockerhub-user: ${{ inputs.dockerhub-user }} - dockerhub-password: ${{ inputs.dockerhub-password }} - github-token: ${{ inputs.github-token }} - only-severities: ${{ inputs.only-severities }} - write-comment: ${{ inputs.write-comment }} - - - name: Docker Scout CVEs - id: cves - uses: docker/scout-action@v1.20.2 - with: - command: cves - image: ${{ inputs.image }} - dockerhub-user: ${{ inputs.dockerhub-user }} - dockerhub-password: ${{ inputs.dockerhub-password }} - github-token: ${{ inputs.github-token }} - only-severities: ${{ inputs.only-severities }} - exit-code: ${{ inputs.exit-code }} - write-comment: ${{ inputs.write-comment }} - - # ----------------- SARIF Export ----------------- - - name: Docker Scout SARIF Export - if: ${{ inputs.sarif-file != '' }} - uses: docker/scout-action@v1.20.2 - with: - command: cves - image: ${{ inputs.image }} - dockerhub-user: ${{ inputs.dockerhub-user }} - dockerhub-password: ${{ inputs.dockerhub-password }} - github-token: ${{ inputs.github-token }} - only-severities: ${{ inputs.only-severities }} - sarif-file: ${{ inputs.sarif-file }} - - # ----------------- Recommendations ----------------- - - name: Docker Scout Recommendations - id: recommendations - if: ${{ inputs.enable-recommendations == 'true' }} - uses: docker/scout-action@v1.20.2 - with: - command: recommendations - image: ${{ inputs.image }} - dockerhub-user: ${{ inputs.dockerhub-user }} - dockerhub-password: ${{ inputs.dockerhub-password }} - github-token: ${{ inputs.github-token }} - - # ----------------- Result Parsing ----------------- - - name: Parse results - id: parse - shell: bash - run: | - QUICKVIEW_OUTPUT='${{ steps.quickview.outputs.quickview }}' - CVES_OUTPUT='${{ steps.cves.outputs.cves }}' - - HAS_VULNS="false" - if echo "$QUICKVIEW_OUTPUT" "$CVES_OUTPUT" | grep -qiE '(critical|high|medium|low)\s+[1-9]'; then - HAS_VULNS="true" - fi - - echo "has_vulnerabilities=$HAS_VULNS" >> "$GITHUB_OUTPUT" 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 index fea6dbdc..197fe89f 100644 --- a/src/security/pr-security-reporter/README.md +++ b/src/security/pr-security-reporter/README.md @@ -5,7 +5,7 @@ -Composite action that posts a formatted security scan summary as a PR comment, combining Trivy filesystem scan, Docker image scan, and Docker Scout results. Updates the comment on subsequent runs instead of creating duplicates. +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 @@ -14,10 +14,9 @@ Composite action that posts a formatted security scan summary as a PR comment, c | `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-docker-scout` | Whether Docker Scout results are present and should be included | No | `false` | -| `scout-quickview` | Docker Scout quickview output | No | `""` | -| `scout-cves` | Docker Scout CVE list output | No | `""` | -| `scout-has-vulnerabilities` | Whether Docker Scout detected vulnerabilities | No | `"false"` | +| `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 @@ -34,13 +33,13 @@ This composite expects the following files in the runner working directory, gene |---|---| | `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 -# Use @develop or your feature branch to test before releasing - name: Post Security Scan Results to PR id: post-results if: always() && github.event_name == 'pull_request' @@ -49,10 +48,8 @@ This composite expects the following files in the runner working directory, gene github-token: ${{ secrets.GITHUB_TOKEN }} app-name: ${{ env.APP_NAME }} enable-docker-scan: ${{ inputs.enable_docker_scan }} - enable-docker-scout: ${{ inputs.enable_docker_scout }} - scout-quickview: ${{ steps.docker-scout.outputs.quickview }} - scout-cves: ${{ steps.docker-scout.outputs.cves }} - scout-has-vulnerabilities: ${{ steps.docker-scout.outputs.has-vulnerabilities }} + enable-health-score: ${{ inputs.enable_health_score }} + dockerfile-has-non-root-user: ${{ steps.dockerfile-checks.outputs.has-non-root-user }} ``` ### Production usage @@ -64,10 +61,27 @@ This composite expects the following files in the runner working directory, gene app-name: my-service ``` -### Gate on findings +### With built-in gate (fail on findings) ```yaml -- name: Gate - Fail on Security Findings +- 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 diff --git a/src/security/pr-security-reporter/action.yml b/src/security/pr-security-reporter/action.yml index ad7f9920..4ee3ecc7 100644 --- a/src/security/pr-security-reporter/action.yml +++ b/src/security/pr-security-reporter/action.yml @@ -12,34 +12,26 @@ inputs: description: Whether Docker image scan artifacts are present and should be included in the report required: false default: "true" - enable-docker-scout: - description: Whether Docker Scout analysis results are present and should be included in the report + enable-health-score: + description: Whether Docker Hub Health Score compliance checks should be included in the report required: false default: "false" - scout-quickview: - description: Docker Scout quickview output (from docker-scout composite outputs.quickview) - required: false - default: "" - scout-cves: - description: Docker Scout CVE list output (from docker-scout composite outputs.cves) - required: false - default: "" - scout-has-vulnerabilities: - description: Whether Docker Scout detected vulnerabilities (from docker-scout composite outputs.has-vulnerabilities) + dockerfile-has-non-root-user: + description: Whether the Dockerfile sets a non-root USER directive required: false default: "false" - scout-recommendations: - description: Docker Scout recommendations output (Dockerfile issues, missing attestations, non-root user) + fail-on-findings: + description: 'Fail the step with exit code 1 when security findings are detected (true/false)' required: false - default: "" + default: "false" outputs: has-findings: description: True if any security vulnerabilities were found - value: ${{ steps.report.outputs.has_findings }} + value: ${{ steps.parse-outputs.outputs.has_findings }} has-errors: description: True if any scan artifacts were missing or could not be parsed - value: ${{ steps.report.outputs.has_errors }} + value: ${{ steps.parse-outputs.outputs.has_errors }} runs: using: composite @@ -50,39 +42,50 @@ runs: env: APP_NAME: ${{ inputs.app-name }} ENABLE_DOCKER_SCAN: ${{ inputs.enable-docker-scan }} - ENABLE_DOCKER_SCOUT: ${{ inputs.enable-docker-scout }} - SCOUT_QUICKVIEW: ${{ inputs.scout-quickview }} - SCOUT_CVES: ${{ inputs.scout-cves }} - SCOUT_HAS_VULNS: ${{ inputs.scout-has-vulnerabilities }} - SCOUT_RECOMMENDATIONS: ${{ inputs.scout-recommendations }} + 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 dockerScoutEnabled = process.env.ENABLE_DOCKER_SCOUT === 'true'; + const healthScoreEnabled = process.env.ENABLE_HEALTH_SCORE === 'true'; + const dockerfileHasNonRootUser = process.env.DOCKERFILE_HAS_NON_ROOT_USER === 'true'; - let body = `## 🔒 Security Scan Results — \`${appName}\`\n\n`; + let body = ''; let hasFindings = false; let hasScanErrors = false; - // Helper to escape markdown table cell content + // ── 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, '\\`'); + 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 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 results) { + for (const result of (data.Results || [])) { for (const v of (result.Vulnerabilities || [])) { vulns.push({ library: v.PkgName || 'Unknown', @@ -90,7 +93,7 @@ runs: severity: v.Severity || 'UNKNOWN', installed: v.InstalledVersion || 'N/A', fixed: v.FixedVersion || 'N/A', - title: v.Title || v.Description || 'No description' + title: v.Title || v.Description || 'No description', }); } for (const s of (result.Secrets || [])) { @@ -100,156 +103,243 @@ runs: severity: s.Severity || 'HIGH', installed: '[REDACTED]', fixed: 'Remove/rotate', - title: s.Title || 'Secret detected' + 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`; + if (vulns.length === 0) { + return `#### Filesystem Scan\n\n\u2705 No vulnerabilities or secrets found.\n\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)); + hasFindings = true; + sortBySeverity(vulns); - 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`; + 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`; } - } else { + + if (vulns.length > MAX) out += `\n_... and ${vulns.length - MAX} more findings._\n`; + return out + `\n`; + } catch (e) { hasScanErrors = true; - body += `### Filesystem Scan\n\n⚠️ Scan artifact not found.\n\n`; + return `#### Filesystem Scan\n\n\u26A0\uFE0F Could not parse scan results: ${e.message}\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) { + // ── Trivy: Docker Image Scan ── + function buildTrivyDockerScan() { + if (!dockerScanEnabled) return ''; + 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 = []; + 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 run of runs) { + 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 || [])) { - 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 (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++; - 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`; + // 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 (dockerVulns.length > 20) { - body += `\n_... and ${dockerVulns.length - 20} more findings._\n`; + 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; + } } - body += `\n`; - } else { - body += `### Docker Image Scan\n\n✅ No vulnerabilities found.\n\n`; + if (!licensePassed) break; } - } 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`; - } - } + } catch {} + policies.push({ name: 'No AGPL v3 licenses', status: licensePassed ? '\u2705 Passed' : '\u26A0\uFE0F Failed', passed: licensePassed }); + if (licensePassed) passCount++; - // Docker Scout Analysis section - if (dockerScoutEnabled && dockerScanEnabled) { - const scoutQuickview = process.env.SCOUT_QUICKVIEW || ''; - const scoutCves = process.env.SCOUT_CVES || ''; - const scoutHasVulns = process.env.SCOUT_HAS_VULNS || 'false'; + const totalPolicies = policies.length; + const failCount = totalPolicies - passCount; + const icon = failCount > 0 ? '\u26A0\uFE0F' : '\u2705'; - body += `### Docker Scout Analysis\n\n`; - if (scoutQuickview) { - body += `
\nQuickview\n\n\`\`\`\n${scoutQuickview}\n\`\`\`\n\n
\n\n`; + 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`; } - if (scoutCves) { - body += `
\nCVE Details\n\n\`\`\`\n${scoutCves}\n\`\`\`\n\n
\n\n`; - } - if (scoutHasVulns === 'true') { + out += '\n'; + + if (failCount > 0) { hasFindings = true; - body += `> **Warning**: Docker Scout detected vulnerabilities.\n\n`; - } else if (!scoutQuickview && !scoutCves) { - hasScanErrors = true; - body += `⚠️ Docker Scout results not available.\n\n`; - } else { - body += `✅ No vulnerabilities detected by Docker Scout.\n\n`; + out += `> \u26A0\uFE0F **Some Docker Hub health score policies are not met. Review the table above.**\n\n`; } - } - // Docker Scout Recommendations section - const scoutRecommendations = process.env.SCOUT_RECOMMENDATIONS || ''; - if (scoutRecommendations) { - body += `### Docker Scout Recommendations\n\n`; - body += `> These are informational — they do not block the build.\n\n`; - body += `
\nView recommendations\n\n\`\`\`\n${scoutRecommendations}\n\`\`\`\n\n
\n\n`; + return out; } - if (!hasFindings && !hasScanErrors) { - body += `\n✅ **All security checks passed.**\n`; - } + // ── Build Report ── + body += `## \u{1F512} Security Scan Results \u2014 \`${appName}\`\n\n`; + body += `## Trivy\n\n`; + body += buildTrivyFsScan(); + body += buildTrivyDockerScan(); + body += buildHealthScoreSection(); - core.setOutput('has_findings', hasFindings); - core.setOutput('has_errors', hasScanErrors); + // ── 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`; - // Find and update existing comment or create new one + // ── 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 + 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({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body - }); + await github.rest.issues.updateComment({ ...params, comment_id: existing.id }); } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body - }); + 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 From 46129c162e0cdc5ec9e0b78de5905ef63555c039 Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:55:36 -0300 Subject: [PATCH 38/40] fix(changed-paths): use channel-aware tag comparison for beta/rc/release --- src/config/changed-paths/action.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/config/changed-paths/action.yml b/src/config/changed-paths/action.yml index 63a26f1a..25714c1d 100644 --- a/src/config/changed-paths/action.yml +++ b/src/config/changed-paths/action.yml @@ -70,13 +70,29 @@ runs: # Tag push or missing before ref CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "") if [[ -n "$CURRENT_TAG" ]]; then - # Compare against previous tag to capture all changes since last release - PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -1 || true) + # 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: comparing $PREV_TAG..$CURRENT_TAG" + echo "Tag push detected ($CHANNEL channel): comparing $PREV_TAG..$CURRENT_TAG" FILES=$(git diff --name-only "$PREV_TAG" HEAD) else - # First tag ever — list all files + # 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 From 10aec972117c76cdc3bc3cc89326e5fc94883282 Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:10:52 -0300 Subject: [PATCH 39/40] feat(ci): add YAML and GitHub Actions lint analysis for PRs (#148) * feat(ci): add YAML and GitHub Actions lint analysis for PRs Add self-pr-analysis workflow that runs yamllint and actionlint on pull requests to validate YAML syntax and GitHub Actions workflow correctness. * fix(ci): scope yamllint and actionlint to changed files only Filter both linters to only check files modified in the PR instead of scanning the entire repository. Falls back to full scan on workflow_dispatch. * fix(ci): register blacksmith runner label for actionlint Add actionlint config with blacksmith-4vcpu-ubuntu-2404 as a known self-hosted runner label to avoid false-positive runner-label errors. * feat(ci): add pinned actions check, markdown link check, typos, and self-pr-validation - Pinned Actions Check: fails on uses: @main/@master (skips LerianStudio) - Markdown Link Check: validates links in changed .md files - Spelling Check: typos-cli across the codebase - self-pr-validation: thin entrypoint calling pr-validation.yml with source branch enforcement for PRs to main - Add .github/markdown-link-check-config.json - Update dependabot groups for new actions * refactor(ci): merge self-pr-analysis into self-pr-validation Consolidate all PR checks into a single self-pr-validation workflow: PR validation, YAML lint, actionlint, pinned actions, markdown link check, and spelling. Remove self-pr-analysis.yml. * feat(lint): add lint composites and refactor self-pr-validation Create 5 composite actions under src/lint/: - yamllint: YAML syntax validation - actionlint: GitHub Actions workflow validation - pinned-actions: ensures uses: refs are pinned - markdown-link-check: validates links in .md files - typos: spelling check via typos-cli Refactor self-pr-validation to use composites directly with a shared changed-files detection job for all lint checks. * feat(config): extract changed-workflows composite from self-pr-validation Create src/config/changed-workflows composite that categorizes changed files by type (YAML, workflows, actions, markdown) for downstream lint jobs. Refactor self-pr-validation to use it. * fix(ci): add changed-files dependency to typos job * fix(config): fix find operator precedence and require github-token in changed-workflows - Fix find -name operator precedence bug: wrap -name flags in \( \) so both .yml and .yaml files are returned in workflow_dispatch fallback - Make github-token required to prevent silent auth failures with gh CLI - Fix yamllint glob pattern to recursively match .yml files * feat(lint): add step summaries to all lint and detection composites Each composite now writes a GitHub Step Summary before running, listing the files it will process (or scope for typos). Feedback is self-contained in each composite, not in the workflow. * refactor(lint): replace step summaries with log output in lint composites * refactor(lint): add file count and list to log step in lint composites * refactor(lint): use group annotations and sed for file log in lint composites * fix(changed-workflows): replace while loop with sed to fix step summary list rendering * fix(lint): action-files csv, actionlint covers src/ composites, log changed-files to stdout * feat(notify): add pr-lint-reporter composite and lint-report job to post PR comments * refactor(notify): replace files collapse with failures collapse in pr-lint-reporter * feat(lint): scope spelling check to changed files only, add all-files detection * feat(notify): fetch job annotations to show per-file errors in failures collapse * fix(lint): use env vars for input interpolation in run blocks, fix grep -Fq, gate typos on changed files * fix(lint): scope actionlint to workflow files only to avoid composite false positives * fix(notify): filter only failure-level annotations in lint reporter to exclude warnings * chore(deps): upgrade actions/checkout to v6 in self-pr-validation * feat(lint): enforce full semver pinning in pinned-actions check * feat(lint): warn on internal unpinned actions instead of failing in pinned-actions check * feat(lint): restrict external actions to final releases only, allow pre-releases for internal * feat(lint): warn on internal pre-release tags (beta/rc) instead of allowing silently * fix(lint): anchor uses: grep to start of line to avoid false matches in shell scripts * fix(lint): align pinned-actions description and error message with actual validation rules - gate lint-report on changed-files success to avoid misleading skipped summaries - paginate annotation fetches in pr-lint-reporter (per_page 100) - fix pinned-actions description and error message to reflect final-release-only rule for externals * chore(lint): clean up markdown-link-check and yamllint configs * ci(self): remove edited trigger to prevent reruns on CodeRabbit updates * fix(lint): tighten markdown config, fix pinned-actions grep pattern, scope workflow permissions * fix(lint): add pull-requests read to changed-files job, strip inline comments in pinned-actions * feat(lint): add shellcheck and readme-check composites to PR lint pipeline * fix(lint): quote shellcheck description, add pr-lint-reporter README * fix(lint): quote step name containing run: in shellcheck composite * fix(lint): remove GHA expression syntax from Python comment in shellcheck * fix(lint): use shell variable placeholder for GHA expressions, exclude SC2154 * fix(lint): respect shell type and count all severity findings in shellcheck * fix(lint): fix GHA regex for nested braces, correct shellcheck line offset * feat(lint): add composite-schema lint to validate input conventions * fix(lint): scope composite-schema to src/ action files only via dedicated output * feat(lint): extend composite-schema with name, description, steps, kebab-case and reserved prefix checks * feat(lint): validate composite directory depth matches src///action.yml * fix(lint): apply directory depth check only after confirming composite action * fix(lint): gate validation job to PR events, harden composite-schema inputs, escape JS file paths with toJSON * fix(lint): add actions:read permission, harden composite-schema type guards, fix README example * fix(lint): fix inputs normalization false-negative and double-count, align README example guard * fix(lint): treat YAML parse errors as violations, enforce action.yml filename in path check --- .github/actionlint.yaml | 3 + .github/dependabot.yml | 13 +- .github/labels.yml | 4 + .github/markdown-link-check-config.json | 26 +++ .github/workflows/self-pr-validation.yml | 214 ++++++++++++++++++++ .yamllint.yml | 20 ++ src/config/changed-workflows/README.md | 52 +++++ src/config/changed-workflows/action.yml | 105 ++++++++++ src/lint/actionlint/README.md | 44 +++++ src/lint/actionlint/action.yml | 39 ++++ src/lint/composite-schema/README.md | 57 ++++++ src/lint/composite-schema/action.yml | 140 +++++++++++++ src/lint/markdown-link-check/README.md | 44 +++++ src/lint/markdown-link-check/action.yml | 34 ++++ src/lint/pinned-actions/README.md | 44 +++++ src/lint/pinned-actions/action.yml | 84 ++++++++ src/lint/readme-check/README.md | 37 ++++ src/lint/readme-check/action.yml | 59 ++++++ src/lint/shellcheck/README.md | 38 ++++ src/lint/shellcheck/action.yml | 137 +++++++++++++ src/lint/typos/README.md | 40 ++++ src/lint/typos/action.yml | 46 +++++ src/lint/yamllint/README.md | 45 +++++ src/lint/yamllint/action.yml | 39 ++++ src/notify/pr-lint-reporter/README.md | 74 +++++++ src/notify/pr-lint-reporter/action.yml | 241 +++++++++++++++++++++++ 26 files changed, 1678 insertions(+), 1 deletion(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/markdown-link-check-config.json create mode 100644 .github/workflows/self-pr-validation.yml create mode 100644 .yamllint.yml create mode 100644 src/config/changed-workflows/README.md create mode 100644 src/config/changed-workflows/action.yml create mode 100644 src/lint/actionlint/README.md create mode 100644 src/lint/actionlint/action.yml create mode 100644 src/lint/composite-schema/README.md create mode 100644 src/lint/composite-schema/action.yml create mode 100644 src/lint/markdown-link-check/README.md create mode 100644 src/lint/markdown-link-check/action.yml create mode 100644 src/lint/pinned-actions/README.md create mode 100644 src/lint/pinned-actions/action.yml create mode 100644 src/lint/readme-check/README.md create mode 100644 src/lint/readme-check/action.yml create mode 100644 src/lint/shellcheck/README.md create mode 100644 src/lint/shellcheck/action.yml create mode 100644 src/lint/typos/README.md create mode 100644 src/lint/typos/action.yml create mode 100644 src/lint/yamllint/README.md create mode 100644 src/lint/yamllint/action.yml create mode 100644 src/notify/pr-lint-reporter/README.md create mode 100644 src/notify/pr-lint-reporter/action.yml 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 b5e87acd..ecf30ca0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -103,12 +103,23 @@ updates: - "minor" - "patch" + # Linting and code quality actions + linting: + patterns: + - "ibiqlik/action-yamllint" + - "raven-actions/actionlint" + - "crate-ci/typos" + - "tcort/github-action-markdown-link-check" + update-types: + - "major" + - "minor" + - "patch" + # Miscellaneous third-party utilities utilities: patterns: - "amannn/action-semantic-pull-request" - "actions/labeler" - - "tcort/github-action-markdown-link-check" - "actions/github-script" - "mikefarah/yq" update-types: diff --git a/.github/labels.yml b/.github/labels.yml index 2983621e..4a6a6cf1 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -87,3 +87,7 @@ - name: notify color: "fbca04" description: Changes to notification composite actions (src/notify/) + +- name: lint + color: "7c3aed" + description: Changes to linting and code quality checks diff --git a/.github/markdown-link-check-config.json b/.github/markdown-link-check-config.json new file mode 100644 index 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/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/.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/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/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/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 }); + } From 6cf6c4a91d986b1ec1e2289e467e129095514cc7 Mon Sep 17 00:00:00 2001 From: Bedatty <79675696+bedatty@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:02:13 -0300 Subject: [PATCH 40/40] feat(changed-paths): add shared_paths input to trigger full matrix on root-level file changes (#155) * feat(changed-paths): add shared_paths input to trigger full matrix on root-level file changes * fix(changed-paths): rename inputs to kebab-case to pass composite-schema lint * fix(workflows): remove trailing spaces and quote GITHUB_OUTPUT redirects (SC2086) * fix(workflows): quote GITHUB_OUTPUT redirects and suppress inapplicable shellcheck rules * fix(workflows): quote GITHUB_OUTPUT, fix SC2188/SC2034/SC2193/SC2001 in remaining workflows * docs(changed-paths): update README input names to kebab-case and add migration table * fix(changed-paths): add jq error handling for malformed filter-paths JSON in shared path branch * fix(gptchangelog): replace useless cat with input redirection (SC2002) * fix(gptchangelog): rename unused APP_NAME/VERSION to _ in changelog PR while loop * fix(changed-paths): align error message and README section to kebab-case naming * docs(changed-paths): update remaining snake_case section headers and prose to kebab-case * docs(changed-paths): move path-level disabled annotation from default cell to description * feat(workflows): propagate shared_paths input to all reusable workflows using changed-paths * fix(workflows): point changed-paths to feat branch for self-consistent testing * fix(workflows): revert changed-paths ref back to develop pre-merge --- .github/workflows/build.yml | 55 +++--- .github/workflows/gptchangelog.yml | 214 ++++++++++++----------- .github/workflows/pr-security-scan.yml | 26 +-- .github/workflows/release.yml | 30 ++-- .github/workflows/typescript-build.yml | 43 +++-- .github/workflows/typescript-release.yml | 28 +-- src/config/changed-paths/README.md | 117 ++++++++----- src/config/changed-paths/action.yml | 164 ++++++++++------- 8 files changed, 401 insertions(+), 276 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ef36bd83..053569ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.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 -> "apps/agent")' type: string @@ -136,12 +141,13 @@ jobs: id: changed-paths 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 @@ -149,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 @@ -169,17 +175,17 @@ jobs: if [[ "$TAG" == *"-beta"* ]] || [[ "$TAG" == *"-rc"* ]]; then if [ "${{ inputs.force_multiplatform }}" == "true" ]; then - echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT - echo "is_release=false" >> $GITHUB_OUTPUT + 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 "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 @@ -223,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 @@ -235,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 }}" @@ -247,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 @@ -257,8 +263,9 @@ 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 diff --git a/.github/workflows/gptchangelog.yml b/.github/workflows/gptchangelog.yml index 6f5c6e8b..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 @@ -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: @@ -143,51 +148,53 @@ jobs: id: changed-paths 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 @@ -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 @@ -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 }} diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 79c50a3e..77910876 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -18,6 +18,11 @@ 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 @@ -88,18 +93,19 @@ jobs: id: changed-paths 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 }} - app_name_prefix: ${{ inputs.app_name_prefix }} - app_name_overrides: ${{ inputs.app_name_overrides }} - normalize_to_filter: ${{ inputs.normalize_to_filter }} - ignore_dirs: | + 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 }} + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f50bbd6..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,14 +47,14 @@ 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) @@ -57,9 +62,10 @@ jobs: id: changed-paths 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 diff --git a/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml index 1b837b48..297b98e3 100644 --- a/.github/workflows/typescript-build.yml +++ b/.github/workflows/typescript-build.yml @@ -35,6 +35,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 @@ -155,12 +160,13 @@ jobs: id: changed-paths 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 @@ -176,21 +182,21 @@ jobs: printf '%s\n' "$COMPONENTS_JSON" echo EOF } >> "$GITHUB_OUTPUT" - echo "has_builds=true" >> $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 + 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 + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_builds=false" >> "$GITHUB_OUTPUT" else { echo 'matrix<> "$GITHUB_OUTPUT" - echo "has_builds=true" >> $GITHUB_OUTPUT + echo "has_builds=true" >> "$GITHUB_OUTPUT" fi fi @@ -206,11 +212,11 @@ jobs: fi TAG="${GITHUB_REF#refs/tags/}" if [[ "$TAG" == *"-beta"* ]] || [[ "$TAG" == *"-rc"* ]]; then - echo "platforms=linux/amd64" >> $GITHUB_OUTPUT - echo "is_release=false" >> $GITHUB_OUTPUT + 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 + echo "platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT" + echo "is_release=true" >> "$GITHUB_OUTPUT" fi - name: Extract version from tag @@ -222,7 +228,7 @@ jobs: TAG="${GITHUB_REF#refs/tags/}" if [ "$REF_TYPE" != "tag" ]; then if [ "$DRY_RUN" == "true" ]; then - echo "version=v0.0.0-dry-run" >> $GITHUB_OUTPUT + echo "version=v0.0.0-dry-run" >> "$GITHUB_OUTPUT" exit 0 fi echo "::error::Cannot extract version — ref is not a tag." @@ -230,8 +236,9 @@ jobs: 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 + echo "version=$VERSION" >> "$GITHUB_OUTPUT" build: needs: prepare diff --git a/.github/workflows/typescript-release.yml b/.github/workflows/typescript-release.yml index 39623c21..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,10 +60,10 @@ 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) @@ -66,9 +71,10 @@ jobs: id: changed-paths 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 diff --git a/src/config/changed-paths/README.md b/src/config/changed-paths/README.md index 1886043f..6cdbb375 100644 --- a/src/config/changed-paths/README.md +++ b/src/config/changed-paths/README.md @@ -9,18 +9,35 @@ Composite action that detects changed files between commits and outputs a matrix ## 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` | 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 | `''` | -| `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 | `''` | +| `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 @@ -37,21 +54,21 @@ steps: 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' + filter-paths: '["components/api", "components/web"]' + path-level: 2 + get-app-name: true + app-name-prefix: 'myapp' ``` ## Output formats -### Default (get_app_name: false) +### Default (get-app-name: false) ```json ["components/api", "components/web"] ``` -### With app names (get_app_name: true) +### With app names (get-app-name: true) ```json [ @@ -60,7 +77,7 @@ steps: ] ``` -### With prefix (app_name_prefix: "myapp") +### With prefix (app-name-prefix: "myapp") ```json [ @@ -73,10 +90,10 @@ steps: ```yaml with: - app_name_overrides: |- + app-name-overrides: |- components/onboarding: components/transaction:tx - app_name_prefix: 'midaz' + app-name-prefix: 'midaz' ``` ```json @@ -86,50 +103,50 @@ with: ] ``` -### With ignore_dirs +### With ignore-dirs ```yaml with: - filter_paths: |- + filter-paths: |- components/api components/web - ignore_dirs: |- + ignore-dirs: |- .github .githooks - get_app_name: true + 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) +### 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: +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' + get-app-name: true + fallback-app-name: 'my-service' ``` ```json [{"name": "my-service", "working_dir": "."}] ``` -### Type 2 monorepo (consolidate_to_root) +### 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`: +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: |- + 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: |- + get-app-name: true + fallback-app-name: 'my-repo' + consolidate-to-root: true + consolidate-keep-dirs: 'frontend' + ignore-dirs: |- .github .githooks ``` @@ -143,15 +160,35 @@ If `components/api` and `frontend` both changed: ] ``` -### With normalize_to_filter +### 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. +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`. +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 +## How path-level works -| Original Path | path_level | Result | +| Original Path | path-level | Result | |---|---|---| | `components/api/src/main.go` | 1 | `components` | | `components/api/src/main.go` | 2 | `components/api` | diff --git a/src/config/changed-paths/action.yml b/src/config/changed-paths/action.yml index 25714c1d..9e80598d 100644 --- a/src/config/changed-paths/action.yml +++ b/src/config/changed-paths/action.yml @@ -2,43 +2,47 @@ 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: + 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: '' - path_level: + 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: + 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: + app-name-prefix: description: 'Prefix to add to each app name when get_app_name is true' required: false default: '' - app_name_overrides: + 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: + 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: + 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: + 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: + 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: + 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: '' @@ -116,16 +120,17 @@ runs: 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 }}" - 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 }}' + 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 @@ -154,58 +159,99 @@ runs: exit 0 fi - # Get directory for each file - DIRS=$(echo "$FILES" | xargs -n1 dirname) + 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 - # 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") + 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 - # 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 [[ "$DIRS_FROM_SHARED" == "true" ]]; then + # Use all filter_paths as the build targets if [[ "$FILTER_PATHS" == "["* ]]; then - # Input looks like JSON array — validate strictly - FILTER_LIST=$(echo "$FILTER_PATHS" | jq -er '.[]' 2>/dev/null) + 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" + 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" + 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 - 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' + # 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 - break - fi - done <<< "$FILTER_LIST" - done <<< "$DIRS" + 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 + # 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" + DIRS="$FILTERED" + fi fi fi