From ce0b46af8dd7a233ce7958c7991675377d7eaebd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:13:06 +0000 Subject: [PATCH 01/10] chore(deps): bump the github-security group with 2 updates Bumps the github-security group with 2 updates: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) and [github/codeql-action/autobuild](https://github.com/github/codeql-action). Updates `github/codeql-action/upload-sarif` from 4.36.2 to 4.36.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...54f647b7e1bb85c95cddabcd46b0c578ec92bc1a) Updates `github/codeql-action/autobuild` from 4.36.2 to 4.36.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...54f647b7e1bb85c95cddabcd46b0c578ec92bc1a) --- updated-dependencies: - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.36.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-security - dependency-name: github/codeql-action/autobuild dependency-version: 4.36.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-security ... Signed-off-by: dependabot[bot] --- .github/workflows/go-pr-analysis.yml | 2 +- .github/workflows/go-security.yml | 4 ++-- .github/workflows/pr-security-scan.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go-pr-analysis.yml b/.github/workflows/go-pr-analysis.yml index e78e2e8e..15a5d9a3 100644 --- a/.github/workflows/go-pr-analysis.yml +++ b/.github/workflows/go-pr-analysis.yml @@ -301,7 +301,7 @@ jobs: - name: Upload Gosec SARIF if: always() - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 with: sarif_file: gosec-${{ matrix.app.name }}.sarif category: gosec-${{ matrix.app.name }} diff --git a/.github/workflows/go-security.yml b/.github/workflows/go-security.yml index 2db12e9c..6e7e21ff 100644 --- a/.github/workflows/go-security.yml +++ b/.github/workflows/go-security.yml @@ -105,7 +105,7 @@ jobs: - name: Upload Gosec SARIF if: always() && inputs.upload_sarif - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 with: sarif_file: gosec-results.sarif category: gosec @@ -175,7 +175,7 @@ jobs: - name: Upload Trivy SARIF if: always() && inputs.upload_sarif - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 with: sarif_file: trivy-results.sarif category: trivy diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 5558355a..b889f50a 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -329,7 +329,7 @@ jobs: - name: Autobuild if: steps.codeql-config.outputs.skip != 'true' - uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 - name: Perform CodeQL Analysis if: steps.codeql-config.outputs.skip != 'true' From 4983a5de63a755f47a21394a1bff5dc311156125 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:15:00 +0000 Subject: [PATCH 02/10] chore(deps): bump trufflesecurity/trufflehog Bumps the security-scanners group with 1 update: [trufflesecurity/trufflehog](https://github.com/trufflesecurity/trufflehog). Updates `trufflesecurity/trufflehog` from 3.95.6 to 3.95.8 - [Release notes](https://github.com/trufflesecurity/trufflehog/releases) - [Commits](https://github.com/trufflesecurity/trufflehog/compare/30d5bb91af1a771378349dbbb0c82129392acf70...00155c9dc586f34d189adc83d3ac2698c2ec551f) --- updated-dependencies: - dependency-name: trufflesecurity/trufflehog dependency-version: 3.95.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: security-scanners ... Signed-off-by: dependabot[bot] --- .github/workflows/go-security.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go-security.yml b/.github/workflows/go-security.yml index 2db12e9c..f00ef793 100644 --- a/.github/workflows/go-security.yml +++ b/.github/workflows/go-security.yml @@ -192,7 +192,7 @@ jobs: fetch-depth: 0 - name: TruffleHog OSS - uses: trufflesecurity/trufflehog@30d5bb91af1a771378349dbbb0c82129392acf70 # v3.95.6 + uses: trufflesecurity/trufflehog@00155c9dc586f34d189adc83d3ac2698c2ec551f # v3.95.8 with: path: ./ base: ${{ github.event.repository.default_branch }} From d9cc329e4eff6fda989e24758e24ee1c0248b415 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 10 Jul 2026 14:39:22 -0300 Subject: [PATCH 03/10] fix(prerelease-check): use keyword allowlist to avoid vendor-suffix false positives The PRERELEASE_PATTERN matched any alphabetical suffix after a semver (x.y.z-[a-zA-Z]), so stable vendor-suffixed releases like HashiCorp's v1.0.1-vault-7 were wrongly flagged as pre-release. Switch go.mod/package.json scanning to the same keyword allowlist already used for Dockerfiles (alpha|beta|rc|dev|preview|canary|snapshot|nightly), so only real pre-release suffixes block. Closes #526 --- src/security/prerelease-check/README.md | 6 +++--- src/security/prerelease-check/action.yml | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/security/prerelease-check/README.md b/src/security/prerelease-check/README.md index 7c4d6df0..99435e32 100644 --- a/src/security/prerelease-check/README.md +++ b/src/security/prerelease-check/README.md @@ -31,12 +31,12 @@ This action never fails the job itself — it only reports. The summary annotati ## What it scans -For `go.mod` and `package.json`: matches any semver with a pre-release suffix starting with a letter (`x.y.z-`). For `Dockerfile`: only matches known pre-release prefixes to avoid false positives on stable image variants. +For all file types, only known pre-release keywords (`alpha`, `beta`, `rc`, `dev`, `preview`, `canary`, `snapshot`, `nightly`) are matched. This allowlist avoids false positives on stable vendor-suffixed releases (e.g. HashiCorp's `v1.0.1-vault-7`) and stable image variants (e.g. `-slim`, `-alpine`, `-bookworm`). | File | Scanned patterns | Blocked (unstable) | Allowed (stable) | |---|---|---|---| -| `go.mod` | `vX.Y.Z-` | `v1.2.3-beta.1`, `v1.2.3-rc.1`, `v1.2.3-alpha.1` | `v1.2.3`, `v0.0.0-20240101-abcdef012345` | -| `package.json` | `"[~^>=]*X.Y.Z-"` | `"^2.0.0-beta.1"`, `"~1.0.0-rc.3"` | `"2.0.0"` | +| `go.mod` | `vX.Y.Z-(alpha\|beta\|rc\|dev\|...)` | `v1.2.3-beta.1`, `v1.2.3-rc.1`, `v1.2.3-alpha.1` | `v1.2.3`, `v1.0.1-vault-7`, `v0.0.0-20240101-abcdef012345` | +| `package.json` | `"[~^>=]*X.Y.Z-(alpha\|beta\|rc\|dev\|...)"` | `"^2.0.0-beta.1"`, `"~1.0.0-rc.3"` | `"2.0.0"` | | `Dockerfile`, `*.dockerfile`, `Dockerfile.*` | `:X.Y.Z-(alpha\|beta\|rc\|dev\|...)` | `golang:1.21.0-beta1` | `golang:1.21.0`, `python:3.12-slim`, `node:20-alpine` | ## Allowlisting accepted pins diff --git a/src/security/prerelease-check/action.yml b/src/security/prerelease-check/action.yml index b263c84f..60ae2e5d 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -47,11 +47,12 @@ runs: BLOCK_BRANCHES: ${{ inputs.block-branches }} ALLOW_FILE: ${{ inputs.allow-file }} run: | - # Matches x.y.z- — any semver with a pre-release suffix starting - # with a letter (alpha, beta, rc, dev, preview, canary, snapshot, etc.). - # Excludes Go pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-commitsha) because - # their suffix starts with digits, not letters. SHA pins are also allowed. - PRERELEASE_PATTERN='[0-9]+\.[0-9]+\.[0-9]+-[a-zA-Z]' + # Matches x.y.z- — only known pre-release suffixes (alpha, beta, + # rc, dev, preview, canary, snapshot, nightly). An allowlist avoids false + # positives on stable vendor-suffixed releases (e.g. HashiCorp's + # v1.0.1-vault-7). Go pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-commitsha) and + # SHA pins are also allowed because their suffix is not a known keyword. + PRERELEASE_PATTERN='[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)' # Docker-specific pattern: only match known pre-release prefixes to avoid # false positives on stable image variants like -slim, -alpine, -bookworm. From 3b8402a727e5e86fde1baeb84bd4de190dfa5578 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 10 Jul 2026 15:10:39 -0300 Subject: [PATCH 04/10] fix(prerelease-check): reword comment to satisfy spell check --- src/security/prerelease-check/README.md | 2 +- src/security/prerelease-check/action.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/security/prerelease-check/README.md b/src/security/prerelease-check/README.md index 99435e32..f99f6d64 100644 --- a/src/security/prerelease-check/README.md +++ b/src/security/prerelease-check/README.md @@ -31,7 +31,7 @@ This action never fails the job itself — it only reports. The summary annotati ## What it scans -For all file types, only known pre-release keywords (`alpha`, `beta`, `rc`, `dev`, `preview`, `canary`, `snapshot`, `nightly`) are matched. This allowlist avoids false positives on stable vendor-suffixed releases (e.g. HashiCorp's `v1.0.1-vault-7`) and stable image variants (e.g. `-slim`, `-alpine`, `-bookworm`). +For all file types, only known pre-release keywords (`alpha`, `beta`, `rc`, `dev`, `preview`, `canary`, `snapshot`, `nightly`) are matched. This allowlist avoids false positives on stable vendor-suffixed releases (e.g. the `-vault-N` suffix, such as `v1.0.1-vault-7`) and stable image variants (e.g. `-slim`, `-alpine`, `-bookworm`). | File | Scanned patterns | Blocked (unstable) | Allowed (stable) | |---|---|---|---| diff --git a/src/security/prerelease-check/action.yml b/src/security/prerelease-check/action.yml index 60ae2e5d..b4d9a4a9 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -49,9 +49,9 @@ runs: run: | # Matches x.y.z- — only known pre-release suffixes (alpha, beta, # rc, dev, preview, canary, snapshot, nightly). An allowlist avoids false - # positives on stable vendor-suffixed releases (e.g. HashiCorp's - # v1.0.1-vault-7). Go pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-commitsha) and - # SHA pins are also allowed because their suffix is not a known keyword. + # positives on stable vendor-suffixed releases (e.g. the -vault-N suffix, + # such as v1.0.1-vault-7). Go pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-commitsha) + # and SHA pins are also allowed because their suffix is not a known keyword. PRERELEASE_PATTERN='[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)' # Docker-specific pattern: only match known pre-release prefixes to avoid From e29d63d43d9f55a97a87976eab27460fc40465c2 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 10 Jul 2026 16:15:33 -0300 Subject: [PATCH 05/10] feat(prerelease-check): allow overriding pattern via org variable Add a prerelease-pattern input to the prerelease-check action and wire it from ${{ vars.PRERELEASE_PATTERN }} in pr-security-scan. This lets the pre-release detection regex be tuned centrally at the org level without cutting a new release. An empty value falls back to the built-in allowlist, so behavior is unchanged for repos that never set the variable. The single pattern now drives go.mod, package.json and Dockerfile scanning. --- .github/workflows/pr-security-scan.yml | 1 + docs/pr-security-scan-workflow.md | 2 ++ src/security/prerelease-check/README.md | 11 +++++++++++ src/security/prerelease-check/action.yml | 16 +++++++++++----- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 5558355a..14eba60a 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -245,6 +245,7 @@ jobs: app-name: ${{ env.APP_NAME }} target-branch: ${{ github.base_ref }} block-branches: ${{ inputs.prerelease_block_branches }} + prerelease-pattern: ${{ vars.PRERELEASE_PATTERN }} # ----------------- Results & Security Gate ----------------- - name: Post Security Scan Results to PR diff --git a/docs/pr-security-scan-workflow.md b/docs/pr-security-scan-workflow.md index 64d34c75..920c6a3d 100644 --- a/docs/pr-security-scan-workflow.md +++ b/docs/pr-security-scan-workflow.md @@ -179,6 +179,8 @@ jobs: When enabled, the workflow scans `go.mod`, `package.json`, and `Dockerfile` for unstable version pins (`-alpha`, `-beta`, `-rc`, `-dev`, etc.). On branches listed in `prerelease_block_branches` (default: `release-candidate,main`) the PR is blocked. On other branches (e.g., `develop`) findings are reported as warnings only. +The detection regex can be overridden centrally through the optional **organization variable `PRERELEASE_PATTERN`** — the workflow forwards `${{ vars.PRERELEASE_PATTERN }}` to the `prerelease-check` action. Define it once at the org level (Settings → Secrets and variables → Actions → Variables) to tune the policy across every consuming repo without cutting a new release; leave it undefined to keep the built-in allowlist (`[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)`). This is a global policy knob, so restrict who can manage Actions variables and prefer a per-pin `.prerelease-allow` allow-file for narrow, reviewable exemptions. + ## Inputs | Input | Type | Default | Description | diff --git a/src/security/prerelease-check/README.md b/src/security/prerelease-check/README.md index f99f6d64..d269f422 100644 --- a/src/security/prerelease-check/README.md +++ b/src/security/prerelease-check/README.md @@ -16,6 +16,7 @@ Composite action that scans dependency files for unstable version pins. Only sta | `target-branch` | PR target branch (e.g. `github.base_ref`). Selects the annotation level (see below). Empty = warning | No | `''` | | `block-branches` | Comma-separated branches where pre-release pins annotate as error. Must match the downstream gate | No | `release-candidate,main` | | `allow-file` | Path (relative to each scanned base) to a file of accepted pre-release pins. Matching findings are exempted (reported as `::notice::`). Absent file = no exemptions | No | `.prerelease-allow` | +| `prerelease-pattern` | Override the semver pre-release regex (applied to `go.mod`, `package.json` and `Dockerfile`). Wire from an org/repo variable to tune the policy without a release. Empty = built-in allowlist | No | `''` | ## Outputs @@ -39,6 +40,16 @@ For all file types, only known pre-release keywords (`alpha`, `beta`, `rc`, `dev | `package.json` | `"[~^>=]*X.Y.Z-(alpha\|beta\|rc\|dev\|...)"` | `"^2.0.0-beta.1"`, `"~1.0.0-rc.3"` | `"2.0.0"` | | `Dockerfile`, `*.dockerfile`, `Dockerfile.*` | `:X.Y.Z-(alpha\|beta\|rc\|dev\|...)` | `golang:1.21.0-beta1` | `golang:1.21.0`, `python:3.12-slim`, `node:20-alpine` | +## Overriding the pattern + +The regex is fixed in the action but overridable through the `prerelease-pattern` input, wired from an organization (or repository) variable so the policy can be tuned centrally without cutting a new release. The `pr-security-scan` reusable workflow already forwards `${{ vars.PRERELEASE_PATTERN }}`; define that variable once at the org level to apply it across every consuming repo. + +- Set it in **Settings → Secrets and variables → Actions → Variables** (organization scope) with a value such as `[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)`. +- Leaving the variable undefined (the default) falls back to the built-in allowlist above — behavior is unchanged for repos that never set it. +- The same override applies to `go.mod`, `package.json` and `Dockerfile` scanning. + +> **Security — this is a global policy knob.** Because the variable is read at org scope, anyone able to edit org variables can loosen (or tighten) the pre-release gate for **every** repository at once. Restrict who can manage Actions variables and prefer the per-pin `.prerelease-allow` allow-file for narrow, reviewable exemptions. + ## Allowlisting accepted pins Some pre-release pins cannot be remediated by upgrade — a direct dependency whose upstream ships no stable release (only `beta`/`rc`), or a pre-release version reached transitively through a dependency you do not control. For these, list the accepted entries in an allow-file (default `.prerelease-allow`, resolved relative to each scanned base — the `scan-ref` directory and the repository root — so a monorepo component can carry its own). Matching findings are exempted and reported as `::notice::` instead of blocking — the same discipline `.trivyignore` applies to CVEs with no fixed version. Any pin **not** listed still blocks. diff --git a/src/security/prerelease-check/action.yml b/src/security/prerelease-check/action.yml index b4d9a4a9..f8dca5bf 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -22,6 +22,10 @@ inputs: description: 'Path (relative to each scanned base) to a file listing accepted pre-release pins as "module version" lines (# comments allowed). Matching findings are exempted and reported as notices — the same discipline .trivyignore applies to CVEs that cannot be remediated by upgrade (e.g. a direct dependency with no stable upstream release). Auto-discovered at the default path when present; absent file = no exemptions.' required: false default: '.prerelease-allow' + prerelease-pattern: + description: 'Override the semver pre-release regex used for go.mod, package.json and Dockerfile scanning. Wire from an org/repo variable (e.g. ${{ vars.PRERELEASE_PATTERN }}) to tune the policy without a release. Empty (the default) uses the built-in allowlist: [0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly).' + required: false + default: '' outputs: has-findings: @@ -46,17 +50,19 @@ runs: TARGET_BRANCH: ${{ inputs.target-branch }} BLOCK_BRANCHES: ${{ inputs.block-branches }} ALLOW_FILE: ${{ inputs.allow-file }} + PRERELEASE_PATTERN: ${{ inputs.prerelease-pattern }} run: | # Matches x.y.z- — only known pre-release suffixes (alpha, beta, # rc, dev, preview, canary, snapshot, nightly). An allowlist avoids false # positives on stable vendor-suffixed releases (e.g. the -vault-N suffix, # such as v1.0.1-vault-7). Go pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-commitsha) # and SHA pins are also allowed because their suffix is not a known keyword. - PRERELEASE_PATTERN='[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)' - - # Docker-specific pattern: only match known pre-release prefixes to avoid - # false positives on stable image variants like -slim, -alpine, -bookworm. - DOCKER_PRERELEASE='[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)' + # The pattern is overridable via the prerelease-pattern input (wired from an + # org/repo variable); an empty value falls back to the built-in allowlist. + # The same pattern drives go.mod, package.json and Dockerfile scanning so + # stable image variants like -slim, -alpine, -bookworm never match either. + PRERELEASE_PATTERN="${PRERELEASE_PATTERN:-[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)}" + DOCKER_PRERELEASE="$PRERELEASE_PATTERN" FINDINGS=() # Scan the component scan-ref AND the repo root. In Go monorepos a single From 6255c84a4d81c36a862777db48d41808f05507b5 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 10 Jul 2026 16:22:13 -0300 Subject: [PATCH 06/10] fix(prerelease-check): require token boundary after pre-release keyword The keyword alternation was prefix-matched, so stable suffixes like -devtools-7 or -rca were flagged as -dev/-rc. Add a token boundary that still accepts numeric and dot/plus-separated forms (-beta1, -beta.1, -rc.2) but rejects longer identifiers. Also fix README accuracy: document the < range comparator supported by the package.json scan and use a full Go pseudo-version in the example. --- src/security/prerelease-check/README.md | 4 ++-- src/security/prerelease-check/action.yml | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/security/prerelease-check/README.md b/src/security/prerelease-check/README.md index d269f422..95130f77 100644 --- a/src/security/prerelease-check/README.md +++ b/src/security/prerelease-check/README.md @@ -36,8 +36,8 @@ For all file types, only known pre-release keywords (`alpha`, `beta`, `rc`, `dev | File | Scanned patterns | Blocked (unstable) | Allowed (stable) | |---|---|---|---| -| `go.mod` | `vX.Y.Z-(alpha\|beta\|rc\|dev\|...)` | `v1.2.3-beta.1`, `v1.2.3-rc.1`, `v1.2.3-alpha.1` | `v1.2.3`, `v1.0.1-vault-7`, `v0.0.0-20240101-abcdef012345` | -| `package.json` | `"[~^>=]*X.Y.Z-(alpha\|beta\|rc\|dev\|...)"` | `"^2.0.0-beta.1"`, `"~1.0.0-rc.3"` | `"2.0.0"` | +| `go.mod` | `vX.Y.Z-(alpha\|beta\|rc\|dev\|...)` | `v1.2.3-beta.1`, `v1.2.3-rc.1`, `v1.2.3-alpha.1` | `v1.2.3`, `v1.0.1-vault-7`, `v0.0.0-20240101120000-abcdef012345` | +| `package.json` | `"[~^<>=]*X.Y.Z-(alpha\|beta\|rc\|dev\|...)"` | `"^2.0.0-beta.1"`, `"~1.0.0-rc.3"`, `"<2.0.0-beta.1"` | `"2.0.0"` | | `Dockerfile`, `*.dockerfile`, `Dockerfile.*` | `:X.Y.Z-(alpha\|beta\|rc\|dev\|...)` | `golang:1.21.0-beta1` | `golang:1.21.0`, `python:3.12-slim`, `node:20-alpine` | ## Overriding the pattern diff --git a/src/security/prerelease-check/action.yml b/src/security/prerelease-check/action.yml index f8dca5bf..d3aacb1d 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -57,11 +57,14 @@ runs: # positives on stable vendor-suffixed releases (e.g. the -vault-N suffix, # such as v1.0.1-vault-7). Go pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-commitsha) # and SHA pins are also allowed because their suffix is not a known keyword. + # A token boundary after each keyword keeps prefix-matches like -devtools-7 + # or -rca from being flagged as -dev/-rc, while still accepting numeric and + # dot/plus-separated forms such as -beta1, -beta.1 and -rc.2. # The pattern is overridable via the prerelease-pattern input (wired from an # org/repo variable); an empty value falls back to the built-in allowlist. # The same pattern drives go.mod, package.json and Dockerfile scanning so # stable image variants like -slim, -alpine, -bookworm never match either. - PRERELEASE_PATTERN="${PRERELEASE_PATTERN:-[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)}" + PRERELEASE_PATTERN="${PRERELEASE_PATTERN:-[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)([[:digit:]]+|[.][[:alnum:]-]+|[+][[:alnum:].-]+)?([^[:alnum:]-]|\$)}" DOCKER_PRERELEASE="$PRERELEASE_PATTERN" FINDINGS=() From 8b48f323b0dd4cf9695ae0a8791a00210bc02105 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 10 Jul 2026 16:30:16 -0300 Subject: [PATCH 07/10] fix(prerelease-check): validate pattern override and treat underscore as token Two hardening fixes for the pre-release regex: - Validate the resolved PRERELEASE_PATTERN with a grep probe before scanning; a malformed override from vars.PRERELEASE_PATTERN would make every "grep -E ... || true" look like zero findings and silently disable the security gate. Fall back to the built-in allowlist with a warning. - Add '_' to the token-boundary class so tags like 1.2.3-dev_build are not flagged as -dev (relevant for Dockerfile scanning of vendor/image tags). --- src/security/prerelease-check/action.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/security/prerelease-check/action.yml b/src/security/prerelease-check/action.yml index d3aacb1d..03c81100 100644 --- a/src/security/prerelease-check/action.yml +++ b/src/security/prerelease-check/action.yml @@ -57,14 +57,27 @@ runs: # positives on stable vendor-suffixed releases (e.g. the -vault-N suffix, # such as v1.0.1-vault-7). Go pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-commitsha) # and SHA pins are also allowed because their suffix is not a known keyword. - # A token boundary after each keyword keeps prefix-matches like -devtools-7 - # or -rca from being flagged as -dev/-rc, while still accepting numeric and + # A token boundary after each keyword keeps prefix-matches like -devtools-7, + # -rca or -dev_build from being flagged as -dev/-rc (underscore counts as + # part of the token, not a delimiter), while still accepting numeric and # dot/plus-separated forms such as -beta1, -beta.1 and -rc.2. # The pattern is overridable via the prerelease-pattern input (wired from an # org/repo variable); an empty value falls back to the built-in allowlist. # The same pattern drives go.mod, package.json and Dockerfile scanning so # stable image variants like -slim, -alpine, -bookworm never match either. - PRERELEASE_PATTERN="${PRERELEASE_PATTERN:-[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)([[:digit:]]+|[.][[:alnum:]-]+|[+][[:alnum:].-]+)?([^[:alnum:]-]|\$)}" + DEFAULT_PRERELEASE_PATTERN='[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|dev|preview|canary|snapshot|nightly)([[:digit:]]+|[.][[:alnum:]-]+|[+][[:alnum:].-]+)?([^[:alnum:]_-]|$)' + PRERELEASE_PATTERN="${PRERELEASE_PATTERN:-$DEFAULT_PRERELEASE_PATTERN}" + + # Guard against a malformed override: an invalid ERE would make every + # "grep -E ... || true" below look like zero findings and silently disable + # the gate. Probe the resolved pattern (grep exits >=2 on a bad regex) and + # fall back to the built-in allowlist with a warning if it is unusable. + PROBE_RC=0 + printf '' | grep -Eq "$PRERELEASE_PATTERN" 2>/dev/null || PROBE_RC=$? + if [ "$PROBE_RC" -ge 2 ]; then + echo "::warning::Invalid prerelease-pattern override; falling back to the built-in allowlist." + PRERELEASE_PATTERN="$DEFAULT_PRERELEASE_PATTERN" + fi DOCKER_PRERELEASE="$PRERELEASE_PATTERN" FINDINGS=() From 3ecd5e8051a307ac47f90e8f6035e55030d49e50 Mon Sep 17 00:00:00 2001 From: "L. Bedatty" <79675696+bedatty@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:58:30 -0300 Subject: [PATCH 08/10] fix(ungoliant-release-diff): add reusable workflow + composite for controller release-diff (#562) --- .github/workflows/go-release.yml | 100 +++- .github/workflows/js-release.yml | 88 ++++ docs/go-release-workflow.md | 32 ++ docs/js-release.md | 31 ++ src/validate/ungoliant-release-diff/README.md | 106 ++++ .../ungoliant-release-diff/action.yml | 479 ++++++++++++++++++ 6 files changed, 834 insertions(+), 2 deletions(-) create mode 100644 src/validate/ungoliant-release-diff/README.md create mode 100644 src/validate/ungoliant-release-diff/action.yml diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml index 1cb6d9ea..b138b66f 100644 --- a/.github/workflows/go-release.yml +++ b/.github/workflows/go-release.yml @@ -201,6 +201,32 @@ on: type: boolean default: true + # ----------------- Ungoliant Release Diff (ungoliant-release-diff composite) ----------------- + enable_ungoliant_release_diff: + description: 'Fire the Ungoliant release-diff webhook on tag push after a successful gitops-update to trigger chaos/fuzz analysis for the release.' + type: boolean + default: false + ungoliant_app: + description: 'App slug sent to the Ungoliant controller. When empty, falls back to app_name, then app_name_prefix, then the repository name.' + type: string + default: '' + ungoliant_env_type: + description: 'Ungoliant environment/testing type — chaos | fuzzing.' + type: string + default: 'chaos' + ungoliant_tenancy: + description: 'Ungoliant tenancy — st (single-tenant) | mt (multi-tenant).' + type: string + default: 'st' + ungoliant_controller_url: + description: 'Ungoliant controller base URL (reachable over Tailscale).' + type: string + default: 'https://ungoliant-controller.anacleto.lerian.net' + ungoliant_runner_type: + description: 'Runner for the Ungoliant release-diff job. Needs Tailscale reach to the controller, so it defaults to eveo-anacleto-lxc-runners rather than the build runner.' + type: string + default: 'eveo-anacleto-lxc-runners' + # ----------------- Shared ----------------- shared_paths: description: 'Newline-separated path patterns that trigger a release/build for all components.' @@ -232,6 +258,8 @@ on: required: false APIDOG_ENVIRONMENT_ID: required: false + UNGOLIANT_WEBHOOK_TOKEN: + required: false permissions: contents: read @@ -533,8 +561,17 @@ jobs: # ----------------- API Dog E2E Tests (tag push, post-gitops, opt-in) ----------------- api_dog_e2e_tests: - needs: [update_gitops] - if: github.ref_type == 'tag' && inputs.enable_apidog_e2e && needs.update_gitops.result == 'success' + needs: [build, update_gitops] + # See the note on ungoliant_release_diff: gate on build's has_builds output + # instead of the unreliable needs.update_gitops.result == 'success'. + if: >- + !cancelled() + && github.ref_type == 'tag' + && inputs.enable_apidog_e2e + && inputs.enable_gitops_update + && needs.build.outputs.has_builds == 'true' + && needs.update_gitops.result != 'failure' + && needs.update_gitops.result != 'cancelled' uses: ./.github/workflows/api-dog-e2e-tests.yml with: runner_type: ${{ inputs.apidog_runner_type }} @@ -545,3 +582,62 @@ jobs: dev_environment_id: ${{ secrets.APIDOG_DEV_ENVIRONMENT_ID }} stg_environment_id: ${{ secrets.APIDOG_STG_ENVIRONMENT_ID }} environment_id: ${{ secrets.APIDOG_ENVIRONMENT_ID }} + + # ----------------- Ungoliant Release Diff (tag push, post-gitops, opt-in) ----------------- + # Runs only after a successful gitops-update (i.e. the release was deployed). + # The controller is reachable only over Tailscale, so the job runs on the + # eveo-anacleto-lxc-runners self-hosted runner. base-env/env are derived from + # the tag, which maps 1:1 to the source branch (beta<-develop, rc<-release- + # candidate, stable<-main) per .releaserc conventions. + ungoliant_release_diff: + name: Ungoliant Release Diff + needs: [build, update_gitops] + # Gate on build's has_builds output (which propagates reliably and proves a + # real deploy happened) rather than needs.update_gitops.result == 'success': + # update_gitops wraps a reusable workflow with a dynamic matrix, and its + # caller result does not reliably surface as 'success' to dependents. + if: >- + !cancelled() + && github.ref_type == 'tag' + && inputs.enable_ungoliant_release_diff + && inputs.enable_gitops_update + && needs.build.outputs.has_builds == 'true' + && needs.update_gitops.result != 'failure' + && needs.update_gitops.result != 'cancelled' + runs-on: ${{ vars.UNGOLIANT_RUNNERS || inputs.ungoliant_runner_type }} + permissions: + contents: read + pull-requests: write + issues: write + steps: + - name: Resolve release channel and base env + id: target + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ "$REF_NAME" == *-beta.* ]]; then + RELEASE_ENV="beta"; BASE_ENV="dev" + elif [[ "$REF_NAME" == *-rc.* ]]; then + RELEASE_ENV="rc"; BASE_ENV="stg" + else + RELEASE_ENV="stable"; BASE_ENV="prd" + fi + { + echo "release_env=$RELEASE_ENV" + echo "base_env=$BASE_ENV" + } >> "$GITHUB_OUTPUT" + + - name: Send release-diff webhook + uses: LerianStudio/github-actions-shared-workflows/src/validate/ungoliant-release-diff@v1 + with: + app: ${{ inputs.ungoliant_app || inputs.app_name || inputs.app_name_prefix || github.event.repository.name }} + version: ${{ github.ref_name }} + env: ${{ steps.target.outputs.release_env }} + env-type: ${{ inputs.ungoliant_env_type }} + tenancy: ${{ inputs.ungoliant_tenancy }} + base-env: ${{ steps.target.outputs.base_env }} + controller-url: ${{ vars.UNGOLIANT_CONTROLLER_URL || inputs.ungoliant_controller_url }} + github-token: ${{ github.token }} + webhook-token: ${{ secrets.UNGOLIANT_WEBHOOK_TOKEN }} + dry-run: ${{ inputs.dry_run }} diff --git a/.github/workflows/js-release.yml b/.github/workflows/js-release.yml index 2d2ea3d4..e8942866 100644 --- a/.github/workflows/js-release.yml +++ b/.github/workflows/js-release.yml @@ -200,6 +200,32 @@ on: type: string default: 'playwright-report' + # ----------------- Ungoliant Release Diff (ungoliant-release-diff composite) ----------------- + enable_ungoliant_release_diff: + description: 'Fire the Ungoliant release-diff webhook on tag push after a successful gitops-update to trigger chaos/fuzz analysis for the release.' + type: boolean + default: false + ungoliant_app: + description: 'App slug sent to the Ungoliant controller. When empty, falls back to app_name_prefix, then the repository name.' + type: string + default: '' + ungoliant_env_type: + description: 'Ungoliant environment/testing type — chaos | fuzzing.' + type: string + default: 'chaos' + ungoliant_tenancy: + description: 'Ungoliant tenancy — st (single-tenant) | mt (multi-tenant).' + type: string + default: 'st' + ungoliant_controller_url: + description: 'Ungoliant controller base URL (reachable over Tailscale).' + type: string + default: 'https://ungoliant-controller.anacleto.lerian.net' + ungoliant_runner_type: + description: 'Runner for the Ungoliant release-diff job. Needs Tailscale reach to the controller, so it defaults to eveo-anacleto-lxc-runners rather than the build runner.' + type: string + default: 'eveo-anacleto-lxc-runners' + secrets: MANAGE_TOKEN: required: false @@ -208,6 +234,8 @@ on: AWS_E2E_ARTIFACTS_ROLE_ARN: description: 'IAM role ARN assumed via OIDC to upload the E2E (Playwright) report to the lerian-e2e-artifacts S3 bucket. Optional — when unset, the S3 upload step is skipped and only the GitHub Actions artifact is produced.' required: false + UNGOLIANT_WEBHOOK_TOKEN: + required: false outputs: has_e2e_s3_upload: @@ -403,3 +431,63 @@ jobs: configmap_updates: ${{ inputs.configmap_updates }} enable_docker_login: ${{ inputs.enable_docker_login }} secrets: inherit + + # ----------------- Ungoliant Release Diff (tag push, post-gitops, opt-in) ----------------- + # Runs only after a successful gitops-update (i.e. the release was deployed). + # The controller is reachable only over Tailscale, so the job runs on the + # eveo-anacleto-lxc-runners self-hosted runner. base-env/env are derived from + # the tag, which maps 1:1 to the source branch (beta<-develop, rc<-release- + # candidate, stable<-main) per .releaserc conventions. + ungoliant_release_diff: + name: Ungoliant Release Diff + needs: [build, update_gitops] + # Gate on build's has_builds output (which propagates reliably and proves a + # real deploy happened) rather than needs.update_gitops.result == 'success': + # update_gitops wraps a reusable workflow with a dynamic matrix, and its + # caller result does not reliably surface as 'success' to dependents. + if: >- + !cancelled() + && github.ref_type == 'tag' + && inputs.enable_ungoliant_release_diff + && inputs.enable_gitops_update + && inputs.enable_gitops_artifacts + && needs.build.outputs.has_builds == 'true' + && needs.update_gitops.result != 'failure' + && needs.update_gitops.result != 'cancelled' + runs-on: ${{ vars.UNGOLIANT_RUNNERS || inputs.ungoliant_runner_type }} + permissions: + contents: read + pull-requests: write + issues: write + steps: + - name: Resolve release channel and base env + id: target + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ "$REF_NAME" == *-beta.* ]]; then + RELEASE_ENV="beta"; BASE_ENV="dev" + elif [[ "$REF_NAME" == *-rc.* ]]; then + RELEASE_ENV="rc"; BASE_ENV="stg" + else + RELEASE_ENV="stable"; BASE_ENV="prd" + fi + { + echo "release_env=$RELEASE_ENV" + echo "base_env=$BASE_ENV" + } >> "$GITHUB_OUTPUT" + + - name: Send release-diff webhook + uses: LerianStudio/github-actions-shared-workflows/src/validate/ungoliant-release-diff@v1 + with: + app: ${{ inputs.ungoliant_app || inputs.app_name_prefix || github.event.repository.name }} + version: ${{ github.ref_name }} + env: ${{ steps.target.outputs.release_env }} + env-type: ${{ inputs.ungoliant_env_type }} + tenancy: ${{ inputs.ungoliant_tenancy }} + base-env: ${{ steps.target.outputs.base_env }} + controller-url: ${{ vars.UNGOLIANT_CONTROLLER_URL || inputs.ungoliant_controller_url }} + github-token: ${{ github.token }} + webhook-token: ${{ secrets.UNGOLIANT_WEBHOOK_TOKEN }} + dry-run: ${{ inputs.dry_run }} diff --git a/docs/go-release-workflow.md b/docs/go-release-workflow.md index 43a3d368..4be3e397 100644 --- a/docs/go-release-workflow.md +++ b/docs/go-release-workflow.md @@ -68,6 +68,12 @@ A third layout needs `release_single_app: true`: **one semantic-release tag for | `enable_apidog_e2e` | Run the ApiDog E2E test job on tag push after a successful gitops-update | boolean | `false` | | `apidog_runner_type` | Runner for the ApiDog E2E test job (needs reach to the deployed environment) | string | `eveo-lxc-runners` | | `apidog_auto_detect_environment` | Auto-detect the ApiDog environment from the tag (beta → dev, rc → stg); when `false`, uses `APIDOG_ENVIRONMENT_ID` | boolean | `true` | +| `enable_ungoliant_release_diff` | Fire the Ungoliant release-diff webhook on tag push after a successful gitops-update (see [Ungoliant release diff](#ungoliant-release-diff)) | boolean | `false` | +| `ungoliant_app` | App slug sent to the controller; when empty falls back to `app_name`, then `app_name_prefix`, then the repo name | string | `''` | +| `ungoliant_env_type` | Ungoliant environment/testing type — `chaos` \| `fuzzing` | string | `chaos` | +| `ungoliant_tenancy` | Ungoliant tenancy — `st` (single-tenant) \| `mt` (multi-tenant) | string | `st` | +| `ungoliant_controller_url` | Ungoliant controller base URL (reachable over Tailscale) | string | `https://ungoliant-controller.anacleto.lerian.net` | +| `ungoliant_runner_type` | Runner for the Ungoliant release-diff job (needs Tailscale reach to the controller) | string | `eveo-anacleto-lxc-runners` | | `shared_paths` | Path patterns that trigger a release/build for all components | string | `''` | | `filter_paths` | Path prefixes to filter (empty = single-app repo) | string | `''` | | `release_single_app` | Force single-app mode for the release job even when `filter_paths` is set (one version tag, many images) | boolean | `false` | @@ -87,6 +93,7 @@ A third layout needs `release_single_app: true`: **one semantic-release tag for | `APIDOG_DEV_ENVIRONMENT_ID` | ApiDog dev environment ID (used for beta tags in auto-detect mode) | No | | `APIDOG_STG_ENVIRONMENT_ID` | ApiDog staging environment ID (used for rc tags in auto-detect mode) | No | | `APIDOG_ENVIRONMENT_ID` | ApiDog environment ID for manual mode (`apidog_auto_detect_environment: false`) | No | +| `UNGOLIANT_WEBHOOK_TOKEN` | Token sent as the `X-Ungoliant-Token` header (used when `enable_ungoliant_release_diff`) | No | ## Usage @@ -154,6 +161,30 @@ with: Set `enable_apidog_e2e: true` to run [api-dog-e2e-tests](./api-dog-e2e-tests-workflow.md) on tag push after a successful `update_gitops`. The job is skipped on branch pushes and when the gitops update did not succeed. Because the underlying workflow expects fixed secret names (`test_scenario_id`, `apidog_access_token`, …), the ApiDog secrets **cannot** be passed via `secrets: inherit` — map them explicitly to the `APIDOG_*` secrets this workflow declares. With `apidog_auto_detect_environment: true` (default), the tag type selects the environment (`-beta.` → `APIDOG_DEV_ENVIRONMENT_ID`, `-rc.` → `APIDOG_STG_ENVIRONMENT_ID`); the underlying workflow errors on tags that are neither beta nor rc, so enable it only for repos that tag pre-release. For manual mode set `apidog_auto_detect_environment: false` and provide `APIDOG_ENVIRONMENT_ID`. + +## Ungoliant release diff + +Set `enable_ungoliant_release_diff: true` to fire the Ungoliant controller `release-diff` webhook on tag push, **only after a successful `update_gitops`** (i.e. the release was actually deployed). The job resolves the diff for the tag and POSTs it to the controller, which triggers chaos/fuzz analysis. It is the CI equivalent of the `ungoliant-controller` `test-release.sh` script and uses the [ungoliant-release-diff](../src/validate/ungoliant-release-diff/README.md) composite. + +The controller is reachable only over Tailscale, so the job runs on the `eveo-anacleto-lxc-runners` self-hosted runner by default (`ungoliant_runner_type`). Inputs are derived automatically: + +- **app** — `ungoliant_app`, else `app_name`, else `app_name_prefix`, else the repo name. +- **version** — the pushed tag (`github.ref_name`). +- **release channel / base env** — derived from the tag, which maps 1:1 to the source branch: `-beta.` → `beta`/`dev` (develop), `-rc.` → `rc`/`stg` (release-candidate), otherwise `stable`/`prd` (main). + +Compose behaviour with `ungoliant_env_type` (`chaos` default, `fuzzing` supported) and `ungoliant_tenancy` (`st` default, `mt` supported). Provide `UNGOLIANT_WEBHOOK_TOKEN` via `secrets: inherit` for an authenticated call; when unset the webhook is sent unauthenticated. + +```yaml +jobs: + pipeline: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/go-release.yml@v1 + with: + enable_ungoliant_release_diff: true + ungoliant_env_type: chaos + ungoliant_tenancy: st + secrets: inherit +``` + ## Multiple build groups By default `go-release` runs **one** `build.yml` call (driven by the top-level `filter_paths`/`app_name_*`/`build_context_from_working_dir` inputs) before the single `update_gitops`. Some repos ship images that need **different build configs in the same release** — e.g. an app + workers built from the repo root, plus a tool/mock image built with `build_context_from_working_dir: true`. These cannot be merged into one `build.yml` call. @@ -237,4 +268,5 @@ The single caller job must grant the union of what the internal jobs need: `id-t - [gitops-update](./gitops-update-workflow.md) — GitOps update this umbrella calls - [s3-upload](./s3-upload.md) — standalone S3 upload reusable workflow (the umbrella performs the equivalent upload inline via `s3_uploads`) - [api-dog-e2e-tests](./api-dog-e2e-tests-workflow.md) — optional post-gitops E2E tests this umbrella calls +- [ungoliant-release-diff](../src/validate/ungoliant-release-diff/README.md) — the composite the optional release-diff job runs - [go-pr-validation](./go-pr-validation.md) — the matching PR validation umbrella diff --git a/docs/js-release.md b/docs/js-release.md index d99b47c1..61d6c069 100644 --- a/docs/js-release.md +++ b/docs/js-release.md @@ -64,6 +64,12 @@ Mirrors the [`go-release`](./go-release-workflow.md) umbrella for Go services | `e2e_base_url` | Base URL injected as `BASE_URL` env var. Empty = localhost fallback | string | `''` | | `node_version` | Node.js version for the E2E runner | string | `22` | | `e2e_s3_artifact_path` | Subpath under `s3://lerian-e2e-artifacts////` where the Playwright report is uploaded. Channel (`main`/`beta`/`rc`) is derived from the tag's prerelease identifier. Only used when `AWS_E2E_ARTIFACTS_ROLE_ARN` is set | string | `playwright-report` | +| `enable_ungoliant_release_diff` | Fire the Ungoliant release-diff webhook on tag push after a successful gitops-update (see [Ungoliant release diff](#ungoliant-release-diff)) | boolean | `false` | +| `ungoliant_app` | App slug sent to the controller; when empty falls back to `app_name_prefix`, then the repo name | string | `''` | +| `ungoliant_env_type` | Ungoliant environment/testing type — `chaos` \| `fuzzing` | string | `chaos` | +| `ungoliant_tenancy` | Ungoliant tenancy — `st` (single-tenant) \| `mt` (multi-tenant) | string | `st` | +| `ungoliant_controller_url` | Ungoliant controller base URL (reachable over Tailscale) | string | `https://ungoliant-controller.anacleto.lerian.net` | +| `ungoliant_runner_type` | Runner for the Ungoliant release-diff job (needs Tailscale reach to the controller) | string | `eveo-anacleto-lxc-runners` | ## Secrets @@ -72,6 +78,7 @@ Mirrors the [`go-release`](./go-release-workflow.md) umbrella for Go services | `MANAGE_TOKEN` | Token for release commits, tags and private module access | No | | `SLACK_WEBHOOK_URL` | Slack webhook for pipeline notifications | No | | `AWS_E2E_ARTIFACTS_ROLE_ARN` | IAM role ARN assumed via OIDC to upload the Playwright report to the `lerian-e2e-artifacts` S3 bucket (`s3://lerian-e2e-artifacts/////`). Unset → S3 upload step is skipped, only the GitHub Actions artifact is produced | No | +| `UNGOLIANT_WEBHOOK_TOKEN` | Token sent as the `X-Ungoliant-Token` header (used when `enable_ungoliant_release_diff`) | No | All other secrets required by the underlying primitives (GitHub App tokens, GPG key, DockerHub credentials, etc.) are forwarded automatically via `secrets: inherit`. @@ -256,9 +263,33 @@ permissions: packages: write ``` +## Ungoliant release diff + +Set `enable_ungoliant_release_diff: true` to fire the Ungoliant controller `release-diff` webhook on tag push, **only after a successful `update_gitops`** (i.e. the release was actually deployed). The job resolves the diff for the tag and POSTs it to the controller, which triggers chaos/fuzz analysis, using the [ungoliant-release-diff](../src/validate/ungoliant-release-diff/README.md) composite. + +The controller is reachable only over Tailscale, so the job runs on the `eveo-anacleto-lxc-runners` self-hosted runner by default (`ungoliant_runner_type`). Inputs are derived automatically: + +- **app** — `ungoliant_app`, else `app_name_prefix`, else the repo name. +- **version** — the pushed tag (`github.ref_name`). +- **release channel / base env** — derived from the tag, which maps 1:1 to the source branch: `-beta.` → `beta`/`dev` (develop), `-rc.` → `rc`/`stg` (release-candidate), otherwise `stable`/`prd` (main). + +Compose behaviour with `ungoliant_env_type` (`chaos` default, `fuzzing` supported) and `ungoliant_tenancy` (`st` default, `mt` supported). Provide `UNGOLIANT_WEBHOOK_TOKEN` via `secrets: inherit` for an authenticated call. + +```yaml +jobs: + pipeline: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/js-release.yml@v1 + with: + enable_ungoliant_release_diff: true + ungoliant_env_type: chaos + ungoliant_tenancy: st + secrets: inherit +``` + ## Related - [release](./release.md) — semantic-release pipeline this umbrella calls on branch push - [typescript-build](./typescript-build.md) — container build & push this umbrella calls - [gitops-update](./gitops-update-workflow.md) — GitOps update this umbrella calls - [go-release](./go-release-workflow.md) — the equivalent umbrella for Go service repositories +- [ungoliant-release-diff](../src/validate/ungoliant-release-diff/README.md) — the composite the release-diff job runs diff --git a/src/validate/ungoliant-release-diff/README.md b/src/validate/ungoliant-release-diff/README.md new file mode 100644 index 00000000..7f00face --- /dev/null +++ b/src/validate/ungoliant-release-diff/README.md @@ -0,0 +1,106 @@ + + + + + +
Lerian

ungoliant-release-diff

+ +Resolves a release diff and fires the `release-diff` webhook at the Ungoliant controller to trigger chaos/fuzz analysis for a release tag. + +This is the CI equivalent of `ungoliant-controller/docs/testing/cluster/test-release.sh`. The action: + +1. Resolves the target repository as `/` and composes `target_env = --`. +2. Auto-resolves the previous tag from the GitHub API when `previous` is empty. +3. Fetches the `previous...version` compare (revision SHA, previous SHA, files changed) and the raw diff, capped at `max-diff-bytes`. +4. Builds the JSON payload (`app, env, repository, version, revision, previous, diff, target_env`). +5. Health-checks the controller `/healthz`. +6. POSTs the payload to `/webhook/release-diff` with the `X-Ungoliant-Token` header and reports the analysis result. + +> The controller is only reachable over Tailscale, so the job must run on a Tailscale-connected self-hosted runner (e.g. `eveo-anacleto-lxc-runners`). + +## Inputs + +| Input | Description | Required | Default | +|------------------|---------------------------------------------------------------------------------|----------|------------------------------------------------------| +| `app` | App slug (midaz, reporter, plugin-fees, …). | Yes | | +| `version` | Tag to test (e.g. `v1.3.4-beta.1`). | Yes | | +| `previous` | Previous tag for the diff. Auto-resolved from the GitHub API when empty. | No | `""` | +| `env` | Release channel — `beta` \| `rc` \| `stable`. | No | `beta` | +| `env-type` | Environment/testing type — `chaos` \| `fuzzing`. Empty produces a legacy `target_env`. | No | `chaos` | +| `tenancy` | Tenancy — `st` (single-tenant) \| `mt` (multi-tenant). | No | `st` | +| `base-env` | Base environment — `dev` \| `stg` \| `prd`. | No | `dev` | +| `controller-url` | Ungoliant controller base URL (reachable over Tailscale). | No | `https://ungoliant-controller.anacleto.lerian.net` | +| `repo-owner` | GitHub owner/org that hosts the app repository. | No | `LerianStudio` | +| `github-token` | GitHub token used to read tags, compare and diff via the API. | Yes | | +| `webhook-token` | Ungoliant webhook token sent as the `X-Ungoliant-Token` header. | No | `""` | +| `max-diff-bytes` | Maximum diff size forwarded to the controller (bytes). | No | `262144` | +| `curl-timeout` | Timeout for the webhook POST in seconds. | No | `300` | +| `dry-run` | Resolve and preview the payload without firing the webhook. | No | `false` | + +## Outputs + +| Output | Description | +|--------------|----------------------------------------------------------| +| `status` | Controller response status (e.g. `analysis_completed`). | +| `run-id` | Controller `run_id` for the analysis. | +| `schema` | Response schema (`release-plan` \| `release-summary`). | +| `risk-level` | Risk level reported by the controller. | +| `target-env` | Composed `target_env` forwarded to the controller. | +| `outcome` | `executed` (smoke/chaos ran) \| `skipped` (no tests warranted). | +| `k6` | Number of k6 smoke scenarios selected. | +| `chaos` | Number of chaos experiments selected. | + +When the controller completes an analysis but selects neither smoke nor chaos +(a low-risk / provably-trivial diff), the step reports `outcome: skipped` and +succeeds — the run makes the skip explicit in its logs and step summary. In both +the `executed` and `skipped` cases the action also posts (or updates) a comment +on the pull request that produced the release, so reviewers see whether the full +Ungoliant flow ran or was skipped. Posting the comment requires the calling job +to grant `pull-requests: write`; it is best-effort and never fails the release. + +## Usage as composite step + +```yaml +jobs: + release-diff: + runs-on: eveo-anacleto-lxc-runners + permissions: + contents: read + steps: + - name: Send release-diff webhook + uses: LerianStudio/github-actions-shared-workflows/src/validate/ungoliant-release-diff@v1 + with: + app: reporter + version: ${{ github.ref_name }} + env: beta + github-token: ${{ secrets.GITHUB_TOKEN }} + webhook-token: ${{ secrets.UNGOLIANT_WEBHOOK_TOKEN }} +``` + +## Usage as reusable workflow + +Prefer the reusable workflow for a one-line integration: + +```yaml +jobs: + release-diff: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/ungoliant-release-diff.yml@develop + with: + app: reporter + version: ${{ github.ref_name }} + secrets: inherit +``` + +## Required permissions + +```yaml +permissions: + contents: read # read tags / compare / diff via the GitHub API +``` + +## Implementation notes + +- Pure Bash + `curl` + `jq` — no `gh` CLI or Python runtime is required on the runner. +- The diff is streamed to the controller via `--data-binary @payload.json`, avoiding the per-argument length limit (`E2BIG`) that a large diff hits when passed as an argv value. +- The byte-level diff cap is UTF-8-sanitised with `iconv -c` so a truncated multi-byte sequence never breaks the JSON payload. +- A `analysis_completed` status is required for the step to succeed; any other status fails the job. diff --git a/src/validate/ungoliant-release-diff/action.yml b/src/validate/ungoliant-release-diff/action.yml new file mode 100644 index 00000000..f435b9c2 --- /dev/null +++ b/src/validate/ungoliant-release-diff/action.yml @@ -0,0 +1,479 @@ +name: Ungoliant Release Diff +description: Resolve a release diff and fire the release-diff webhook at the Ungoliant controller to trigger chaos/fuzz analysis. + +inputs: + app: + description: App slug (midaz, reporter, plugin-fees, …) + required: true + version: + description: Tag to test (e.g. v1.3.4-beta.1) + required: true + previous: + description: Previous tag for the diff. Auto-resolved from the GitHub API when empty. + required: false + default: "" + env: + description: Release channel — beta | rc | stable + required: false + default: "beta" + env-type: + description: Environment/testing type — chaos | fuzzing. Empty produces a legacy target env. + required: false + default: "chaos" + tenancy: + description: Tenancy — st (single-tenant) | mt (multi-tenant) + required: false + default: "st" + base-env: + description: Base environment — dev | stg | prd + required: false + default: "dev" + controller-url: + description: Ungoliant controller base URL (reachable over Tailscale) + required: false + default: "https://ungoliant-controller.anacleto.lerian.net" + repo-owner: + description: GitHub owner/org that hosts the app repository + required: false + default: "LerianStudio" + github-token: + description: GitHub token used to read tags, compare and diff via the API + required: true + webhook-token: + description: Ungoliant webhook token sent as the X-Ungoliant-Token header + required: false + default: "" + max-diff-bytes: + description: Maximum diff size forwarded to the controller (bytes) + required: false + default: "262144" + curl-timeout: + description: Timeout for the webhook POST in seconds (LLM inference can take 60-120s) + required: false + default: "300" + dry-run: + description: Resolve and preview the payload without firing the webhook + required: false + default: "false" + +outputs: + status: + description: Controller response status (e.g. analysis_completed) + value: ${{ steps.webhook.outputs.status }} + run-id: + description: Controller run_id for the analysis + value: ${{ steps.webhook.outputs.run_id }} + schema: + description: Response schema (release-plan | release-summary) + value: ${{ steps.webhook.outputs.schema }} + risk-level: + description: Risk level reported by the controller + value: ${{ steps.webhook.outputs.risk_level }} + target-env: + description: Composed target env forwarded to the controller + value: ${{ steps.resolve.outputs.target_env }} + outcome: + description: High-level outcome — executed (smoke/chaos ran) | skipped (no tests warranted) + value: ${{ steps.webhook.outputs.outcome }} + k6: + description: Number of k6 smoke scenarios selected by the controller + value: ${{ steps.webhook.outputs.k6 }} + chaos: + description: Number of chaos experiments selected by the controller + value: ${{ steps.webhook.outputs.chaos }} + +runs: + using: composite + steps: + # ----------------- Resolve Repo & Target Env ----------------- + - name: Resolve config + id: resolve + shell: bash + env: + APP: ${{ inputs.app }} + REPO_OWNER: ${{ inputs.repo-owner }} + ENV_TYPE: ${{ inputs.env-type }} + TENANCY: ${{ inputs.tenancy }} + BASE_ENV: ${{ inputs.base-env }} + run: | + set -euo pipefail + + if [ -z "$APP" ]; then + echo "::error::app input is required" + exit 1 + fi + + if [ -n "$ENV_TYPE" ] && [ "$ENV_TYPE" != "chaos" ] && [ "$ENV_TYPE" != "fuzzing" ]; then + echo "::error::env-type must be 'chaos' or 'fuzzing' (got: $ENV_TYPE)" + exit 1 + fi + if [ -n "$TENANCY" ] && [ "$TENANCY" != "st" ] && [ "$TENANCY" != "mt" ]; then + echo "::error::tenancy must be 'st' or 'mt' (got: $TENANCY)" + exit 1 + fi + + REPO="${REPO_OWNER}/${APP}" + + if [ -n "$ENV_TYPE" ]; then + TARGET_ENV="${ENV_TYPE}-${BASE_ENV}-${TENANCY}" + else + TARGET_ENV="${BASE_ENV}" + fi + + { + echo "repo=$REPO" + echo "target_env=$TARGET_ENV" + } >> "$GITHUB_OUTPUT" + + # ----------------- Resolve Previous Tag ----------------- + - name: Resolve previous tag + id: previous + shell: bash + env: + REPO: ${{ steps.resolve.outputs.repo }} + VERSION: ${{ inputs.version }} + PREVIOUS: ${{ inputs.previous }} + GH_TOKEN: ${{ inputs.github-token }} + run: | + set -euo pipefail + + if [ -z "$VERSION" ]; then + echo "::error::version input is required" + exit 1 + fi + + PREV="$PREVIOUS" + if [ -z "$PREV" ]; then + echo "Resolving previous tag for $REPO..." + TAGS_JSON="$(curl -sf --max-time 30 \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${REPO}/tags?per_page=100")" + PREV="$(printf '%s' "$TAGS_JSON" | jq -r --arg v "$VERSION" ' + [.[].name] as $n + | ($n | index($v)) as $i + | if $i == null then + (if ($n | length) > 1 then $n[1] else "" end) + else + (if ($i + 1) < ($n | length) then $n[$i + 1] else "" end) + end')" + [ -n "$PREV" ] || { echo "::error::Could not resolve previous tag — pass it explicitly"; exit 1; } + echo "Previous tag resolved: $PREV" + fi + + echo "previous=$PREV" >> "$GITHUB_OUTPUT" + + # ----------------- Fetch Compare & Diff ----------------- + - name: Fetch compare and diff + id: diff + shell: bash + env: + REPO: ${{ steps.resolve.outputs.repo }} + VERSION: ${{ inputs.version }} + PREVIOUS: ${{ steps.previous.outputs.previous }} + MAX_DIFF_BYTES: ${{ inputs.max-diff-bytes }} + GH_TOKEN: ${{ inputs.github-token }} + run: | + set -euo pipefail + + API="https://api.github.com/repos/${REPO}" + + echo "Fetching compare ${PREVIOUS} -> ${VERSION} from ${REPO}..." + COMPARE="$(curl -sf --max-time 60 \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "${API}/compare/${PREVIOUS}...${VERSION}")" + + REVISION="$(printf '%s' "$COMPARE" | jq -r 'if (.commits | length) > 0 then .commits[-1].sha[0:12] else "" end')" + if [ -z "$REVISION" ]; then + REVISION="$(curl -sf --max-time 30 \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "${API}/git/ref/tags/${VERSION}" | jq -r '.object.sha[0:12]')" + fi + + PREV_SHA="$(curl -sf --max-time 30 \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "${API}/git/ref/tags/${PREVIOUS}" | jq -r '.object.sha[0:12]')" + + FILES="$(printf '%s' "$COMPARE" | jq -r '.files | length')" + echo "Revision: $REVISION | Previous: $PREV_SHA | Files changed: $FILES" + + echo "Fetching diff (cap ${MAX_DIFF_BYTES} bytes)..." + curl -sf --max-time 120 \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github.diff" \ + "${API}/compare/${PREVIOUS}...${VERSION}" -o diff.raw + + head -c "$MAX_DIFF_BYTES" diff.raw > diff.trunc + # Strip any UTF-8 sequence left incomplete by the byte-level truncation. + iconv -f UTF-8 -t UTF-8 -c diff.trunc > diff.txt 2>/dev/null || cp diff.trunc diff.txt + + DIFF_SIZE="$(wc -c < diff.txt | tr -d ' ')" + echo "Diff size: ${DIFF_SIZE} bytes" + [ "$DIFF_SIZE" -gt 0 ] || { echo "::error::Empty diff — check tags exist in $REPO"; exit 1; } + + { + echo "revision=$REVISION" + echo "prev_sha=$PREV_SHA" + echo "files=$FILES" + } >> "$GITHUB_OUTPUT" + + # ----------------- Build Payload ----------------- + - name: Build payload + shell: bash + env: + APP: ${{ inputs.app }} + ENV: ${{ inputs.env }} + REPO: ${{ steps.resolve.outputs.repo }} + VERSION: ${{ inputs.version }} + REVISION: ${{ steps.diff.outputs.revision }} + PREV_SHA: ${{ steps.diff.outputs.prev_sha }} + TARGET_ENV: ${{ steps.resolve.outputs.target_env }} + run: | + set -euo pipefail + jq -n \ + --rawfile diff diff.txt \ + --arg app "$APP" \ + --arg env "$ENV" \ + --arg repo "$REPO" \ + --arg version "$VERSION" \ + --arg revision "$REVISION" \ + --arg previous "$PREV_SHA" \ + --arg target_env "$TARGET_ENV" \ + '{app: $app, env: $env, repository: $repo, version: $version, revision: $revision, previous: $previous, diff: $diff} + + (if $target_env == "" then {} else {target_env: $target_env} end)' > payload.json + + # ----------------- Health Check ----------------- + - name: Health check controller + shell: bash + env: + CONTROLLER_URL: ${{ inputs.controller-url }} + run: | + set -euo pipefail + echo "Checking controller at ${CONTROLLER_URL}..." + HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "${CONTROLLER_URL}/healthz" || true)" + [ "$HTTP_CODE" = "200" ] || { echo "::error::Controller not reachable ($HTTP_CODE). Is the runner on Tailscale?"; exit 1; } + echo "Controller is up" + + # ----------------- Dry Run ----------------- + - name: Dry run summary + if: inputs.dry-run == 'true' + shell: bash + env: + APP: ${{ inputs.app }} + VERSION: ${{ inputs.version }} + ENV: ${{ inputs.env }} + TARGET_ENV: ${{ steps.resolve.outputs.target_env }} + REPO: ${{ steps.resolve.outputs.repo }} + PREVIOUS: ${{ steps.previous.outputs.previous }} + REVISION: ${{ steps.diff.outputs.revision }} + run: | + set -euo pipefail + echo "::notice::DRY RUN — release-diff webhook will not be sent" + echo " app : $APP" + echo " repository : $REPO" + echo " version : $VERSION" + echo " previous : $PREVIOUS" + echo " revision : $REVISION" + echo " env : $ENV" + echo " target_env : $TARGET_ENV" + echo " payload size: $(wc -c < payload.json | tr -d ' ') bytes" + + # ----------------- Fire Webhook ----------------- + - name: Send release-diff webhook + id: webhook + if: inputs.dry-run != 'true' + shell: bash + env: + CONTROLLER_URL: ${{ inputs.controller-url }} + CURL_TIMEOUT: ${{ inputs.curl-timeout }} + WEBHOOK_TOKEN: ${{ inputs.webhook-token }} + APP: ${{ inputs.app }} + VERSION: ${{ inputs.version }} + ENV: ${{ inputs.env }} + TARGET_ENV: ${{ steps.resolve.outputs.target_env }} + run: | + set -euo pipefail + + echo "Sending release-diff webhook (timeout ${CURL_TIMEOUT}s)..." + echo " app=$APP version=$VERSION env=$ENV target_env=$TARGET_ENV" + [ -n "$WEBHOOK_TOKEN" ] || echo "::warning::UNGOLIANT_WEBHOOK_TOKEN not set — sending unauthenticated" + + # The payload is streamed via --data-binary @- to avoid the per-argument + # length limit (E2BIG) that a large diff would hit if passed as argv. + CURL_ARGS=(-s --max-time "$CURL_TIMEOUT" -X POST + "${CONTROLLER_URL}/webhook/release-diff" + -H "Content-Type: application/json" + --data-binary @payload.json) + [ -n "$WEBHOOK_TOKEN" ] && CURL_ARGS+=(-H "X-Ungoliant-Token: $WEBHOOK_TOKEN") + + START="$(date +%s)" + RESPONSE="$(curl "${CURL_ARGS[@]}" 2>&1 || true)" + ELAPSED=$(( $(date +%s) - START )) + + echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE" + echo "Elapsed: ${ELAPSED}s" + + STATUS="$(printf '%s' "$RESPONSE" | jq -r '.status // "parse_error"' 2>/dev/null || echo "parse_error")" + RUN_ID="$(printf '%s' "$RESPONSE" | jq -r '.run_id // ""' 2>/dev/null || echo "")" + SCHEMA="$(printf '%s' "$RESPONSE" | jq -r '.schema // "?"' 2>/dev/null || echo "?")" + RISK="$(printf '%s' "$RESPONSE" | jq -r '.risk_level // "?"' 2>/dev/null || echo "?")" + K6_COUNT="$(printf '%s' "$RESPONSE" | jq -r '.k6 // 0' 2>/dev/null || echo 0)" + CHAOS_COUNT="$(printf '%s' "$RESPONSE" | jq -r '.chaos // 0' 2>/dev/null || echo 0)" + + # A completed analysis that selected neither smoke nor chaos is a + # deliberate skip: the controller judged the diff low-risk / provably + # trivial (doc-only or CI/linter cosmetics) and ran no tests. Surface + # that as a first-class outcome so the release makes the decision clear + # instead of it hiding behind a generic "analysis_completed". + OUTCOME="failed" + if [ "$STATUS" = "analysis_completed" ]; then + if [ "$K6_COUNT" = "0" ] && [ "$CHAOS_COUNT" = "0" ]; then + OUTCOME="skipped" + else + OUTCOME="executed" + fi + fi + + { + echo "status=$STATUS" + echo "run_id=$RUN_ID" + echo "schema=$SCHEMA" + echo "risk_level=$RISK" + echo "k6=$K6_COUNT" + echo "chaos=$CHAOS_COUNT" + echo "outcome=$OUTCOME" + } >> "$GITHUB_OUTPUT" + + if [ "$OUTCOME" = "skipped" ]; then + SUMMARY_TITLE="### Ungoliant release-diff — SKIPPED (no tests warranted)" + elif [ "$OUTCOME" = "executed" ]; then + SUMMARY_TITLE="### Ungoliant release-diff — EXECUTED (full flow)" + else + SUMMARY_TITLE="### Ungoliant release-diff — FAILED" + fi + + { + echo "$SUMMARY_TITLE" + echo "" + echo "| Field | Value |" + echo "| --- | --- |" + echo "| outcome | \`$OUTCOME\` |" + echo "| status | \`$STATUS\` |" + echo "| run_id | \`$RUN_ID\` |" + echo "| schema | \`$SCHEMA\` |" + echo "| risk_level | \`$RISK\` |" + echo "| target_env | \`$TARGET_ENV\` |" + echo "| k6 | \`$K6_COUNT\` |" + echo "| chaos | \`$CHAOS_COUNT\` |" + echo "| elapsed | \`${ELAPSED}s\` |" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$OUTCOME" = "failed" ]; then + echo "::error::Ungoliant analysis failed — status: $STATUS (elapsed ${ELAPSED}s)" + exit 1 + fi + if [ "$OUTCOME" = "skipped" ]; then + echo "::notice::Ungoliant SKIPPED test execution in ${ELAPSED}s — no smoke/chaos warranted (run_id=$RUN_ID, risk=$RISK)" + else + echo "::notice::Ungoliant EXECUTED full flow in ${ELAPSED}s (run_id=$RUN_ID, schema=$SCHEMA, risk=$RISK, k6=$K6_COUNT, chaos=$CHAOS_COUNT)" + fi + + # ----------------- Comment on Originating PR ----------------- + # Best-effort: post/update a comment on the PR that produced this release + # stating whether Ungoliant ran the full flow or skipped it. Never fails + # the release — an API/permission hiccup only logs a warning. + - name: Comment ungoliant outcome on originating PR + if: inputs.dry-run != 'true' && (steps.webhook.outputs.outcome == 'executed' || steps.webhook.outputs.outcome == 'skipped') + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + RUN_REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + SERVER_URL: ${{ github.server_url }} + RUN_ID_GH: ${{ github.run_id }} + APP: ${{ inputs.app }} + VERSION: ${{ inputs.version }} + TARGET_ENV: ${{ steps.resolve.outputs.target_env }} + OUTCOME: ${{ steps.webhook.outputs.outcome }} + STATUS: ${{ steps.webhook.outputs.status }} + SCHEMA: ${{ steps.webhook.outputs.schema }} + RISK: ${{ steps.webhook.outputs.risk_level }} + K6_COUNT: ${{ steps.webhook.outputs.k6 }} + CHAOS_COUNT: ${{ steps.webhook.outputs.chaos }} + RUN_ID: ${{ steps.webhook.outputs.run_id }} + run: | + set -uo pipefail + API="https://api.github.com/repos/${RUN_REPO}" + AUTH=(-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json") + + # Resolve the PR behind this release commit. Primary: PRs GitHub + # associates with the tagged commit. Fallback: the last (#NNN) in the + # commit message (squash-merge title). + PR_NUMBER="$(curl -sf --max-time 30 "${AUTH[@]}" \ + "${API}/commits/${SHA}/pulls" 2>/dev/null \ + | jq -r 'if length > 0 then .[0].number else "" end' 2>/dev/null || echo "")" + if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then + MSG="$(curl -sf --max-time 30 "${AUTH[@]}" "${API}/commits/${SHA}" 2>/dev/null \ + | jq -r '.commit.message' 2>/dev/null || echo "")" + PR_NUMBER="$(printf '%s' "$MSG" | grep -oE '#[0-9]+' | tail -1 | tr -d '#' || echo "")" + fi + if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then + echo "::warning::Could not resolve an originating PR for ${SHA} — skipping PR comment" + exit 0 + fi + echo "Originating PR: #${PR_NUMBER}" + + RUN_URL="${SERVER_URL}/${RUN_REPO}/actions/runs/${RUN_ID_GH}" + MARKER="" + if [ "$OUTCOME" = "skipped" ]; then + HEADER="## Ungoliant Release Diff — Skipped" + BLURB="No smoke or chaos tests were run for this release. Ungoliant judged the diff low-risk / provably trivial (doc-only or CI/linter cosmetics), so test execution was skipped." + else + HEADER="## Ungoliant Release Diff — Executed" + BLURB="The full chaos/fuzz analysis flow ran for this release." + fi + + BODY="" + add() { BODY="${BODY}${1}"$'\n'; } + add "$MARKER" + add "$HEADER" + add "" + add "$BLURB" + add "" + add "| Field | Value |" + add "| --- | --- |" + add "| outcome | \`${OUTCOME}\` |" + add "| app | \`${APP}\` |" + add "| version | \`${VERSION}\` |" + add "| target_env | \`${TARGET_ENV}\` |" + add "| risk_level | \`${RISK}\` |" + add "| k6 smoke scenarios | \`${K6_COUNT}\` |" + add "| chaos experiments | \`${CHAOS_COUNT}\` |" + add "| schema | \`${SCHEMA}\` |" + add "| run_id | \`${RUN_ID}\` |" + add "" + add "[Workflow run](${RUN_URL})" + + jq -n --arg body "$BODY" '{body: $body}' > comment.json + + # Upsert: update the existing comment for this version if present, + # otherwise create a new one. + EXISTING_ID="$(curl -sf --max-time 30 "${AUTH[@]}" \ + "${API}/issues/${PR_NUMBER}/comments?per_page=100" 2>/dev/null \ + | jq -r --arg m "$MARKER" '[.[] | select((.body // "") | contains($m))] | if length > 0 then .[-1].id else "" end' 2>/dev/null || echo "")" + + if [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then + METHOD="PATCH"; URL="${API}/issues/comments/${EXISTING_ID}"; WHAT="update comment ${EXISTING_ID}" + else + METHOD="POST"; URL="${API}/issues/${PR_NUMBER}/comments"; WHAT="post comment" + fi + HTTP_CODE="$(curl -s -o /tmp/ungoliant_comment_resp.json -w '%{http_code}' --max-time 30 \ + "${AUTH[@]}" -X "$METHOD" "$URL" --data-binary @comment.json 2>/dev/null || echo "000")" + if [ "${HTTP_CODE:0:1}" = "2" ]; then + echo "Ungoliant PR comment ${METHOD}ed on #${PR_NUMBER} (HTTP ${HTTP_CODE})" + else + echo "::warning::Failed to ${WHAT} on PR #${PR_NUMBER} — HTTP ${HTTP_CODE}: $(head -c 300 /tmp/ungoliant_comment_resp.json 2>/dev/null)" + fi From b9848d82cf8f91d22b98a507ecc6c1dc36821ce4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:03:43 -0300 Subject: [PATCH 09/10] chore(deps): bump the docker group with 5 updates (#546) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 12 ++++++------ .github/workflows/gitops-update.yml | 2 +- .github/workflows/pr-security-scan.yml | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b5f03bd..57e0e1b8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -275,21 +275,21 @@ jobs: - name: Set up QEMU if: contains(needs.prepare.outputs.platforms, 'arm64') - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Log in to DockerHub if: inputs.enable_dockerhub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKERHUB_IMAGE_PUSH_TOKEN }} - name: Log in to GHCR if: inputs.enable_ghcr - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -407,7 +407,7 @@ jobs: - name: Docker metadata if: steps.preflight.outputs.should_build != 'false' id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: images: ${{ steps.image-names.outputs.images }} tags: | @@ -416,7 +416,7 @@ jobs: - name: Build and push Docker image if: steps.preflight.outputs.should_build != 'false' id: build-push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: ${{ inputs.build_context_from_working_dir == true && matrix.app.working_dir || 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 56e1708a..2f6a46b8 100644 --- a/.github/workflows/gitops-update.yml +++ b/.github/workflows/gitops-update.yml @@ -129,7 +129,7 @@ jobs: steps: - name: Log in to Docker Hub if: ${{ inputs.enable_docker_login }} - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKERHUB_IMAGE_PULL_TOKEN }} diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 14eba60a..a1fd8157 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -133,7 +133,7 @@ jobs: steps: # ----------------- Setup ----------------- - name: Login to Docker Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: registry: ${{ inputs.docker_registry }} username: ${{ secrets.DOCKER_USERNAME }} @@ -175,7 +175,7 @@ jobs: steps: # ----------------- Setup ----------------- - name: Login to Docker Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: registry: ${{ inputs.docker_registry }} username: ${{ secrets.DOCKER_USERNAME }} @@ -186,7 +186,7 @@ jobs: - name: Set up Docker Buildx if: inputs.enable_docker_scan - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 # ----------------- Filesystem Scanning (Secrets + Vulnerabilities) ----------------- - name: Trivy Filesystem Scan @@ -202,7 +202,7 @@ jobs: # ----------------- Docker Build ----------------- - name: Build Docker Image for Scanning if: always() && inputs.enable_docker_scan - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: # yamllint disable-line rule:line-length context: ${{ inputs.build_context_from_working_dir == true && matrix.working_dir || (inputs.monorepo_type == 'type2' && matrix.working_dir == inputs.frontend_folder && inputs.frontend_folder || '.') }} From 143c30e23b494e842f42358288ddff50b308fb92 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 10 Jul 2026 17:35:58 -0300 Subject: [PATCH 10/10] fix(ungoliant-release-diff): robust previous-tag fallback, retries, fail-fast health check - Resolve the previous tag deterministically: paginate all tags and version-sort (sort -rV) instead of trusting the /tags response order or a single 100-tag page, which could resolve the wrong previous diff on repos with many tags. - Add --retry 2 --retry-delay 2 to the read-only GitHub GETs (tags/compare/diff) to reduce release-pipeline flakiness from transient blips; the webhook POST is intentionally left without retries to avoid duplicate chaos runs. - Move the controller /healthz check ahead of the GitHub API calls to fail fast and avoid wasting rate-limited requests when the controller is down. - Document pull-requests: write in the README permissions examples so callers copying them get a working PR-comment feature. Addresses CodeRabbit review on PR #566. --- src/validate/ungoliant-release-diff/README.md | 6 +- .../ungoliant-release-diff/action.yml | 64 +++++++++++-------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/validate/ungoliant-release-diff/README.md b/src/validate/ungoliant-release-diff/README.md index 7f00face..25a69d0d 100644 --- a/src/validate/ungoliant-release-diff/README.md +++ b/src/validate/ungoliant-release-diff/README.md @@ -65,7 +65,8 @@ jobs: release-diff: runs-on: eveo-anacleto-lxc-runners permissions: - contents: read + contents: read # read tags / compare / diff via the GitHub API + pull-requests: write # optional: post/update the outcome comment on the originating PR steps: - name: Send release-diff webhook uses: LerianStudio/github-actions-shared-workflows/src/validate/ungoliant-release-diff@v1 @@ -95,7 +96,8 @@ jobs: ```yaml permissions: - contents: read # read tags / compare / diff via the GitHub API + contents: read # read tags / compare / diff via the GitHub API + pull-requests: write # optional: post/update the outcome comment on the originating PR ``` ## Implementation notes diff --git a/src/validate/ungoliant-release-diff/action.yml b/src/validate/ungoliant-release-diff/action.yml index f435b9c2..14776e6d 100644 --- a/src/validate/ungoliant-release-diff/action.yml +++ b/src/validate/ungoliant-release-diff/action.yml @@ -125,6 +125,18 @@ runs: echo "target_env=$TARGET_ENV" } >> "$GITHUB_OUTPUT" + # ----------------- Health Check (fail fast before GitHub API calls) ----------------- + - name: Health check controller + shell: bash + env: + CONTROLLER_URL: ${{ inputs.controller-url }} + run: | + set -euo pipefail + echo "Checking controller at ${CONTROLLER_URL}..." + HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "${CONTROLLER_URL}/healthz" || true)" + [ "$HTTP_CODE" = "200" ] || { echo "::error::Controller not reachable ($HTTP_CODE). Is the runner on Tailscale?"; exit 1; } + echo "Controller is up" + # ----------------- Resolve Previous Tag ----------------- - name: Resolve previous tag id: previous @@ -145,18 +157,26 @@ runs: PREV="$PREVIOUS" if [ -z "$PREV" ]; then echo "Resolving previous tag for $REPO..." - TAGS_JSON="$(curl -sf --max-time 30 \ - -H "Authorization: Bearer $GH_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${REPO}/tags?per_page=100")" - PREV="$(printf '%s' "$TAGS_JSON" | jq -r --arg v "$VERSION" ' - [.[].name] as $n - | ($n | index($v)) as $i - | if $i == null then - (if ($n | length) > 1 then $n[1] else "" end) - else - (if ($i + 1) < ($n | length) then $n[$i + 1] else "" end) - end')" + # The /tags API guarantees no ordering and caps at 100 per page, so + # collect every page and sort deterministically instead of trusting + # the response order (which could resolve the wrong previous tag). + ALL_TAGS="" + page=1 + while [ "$page" -le 20 ]; do + PAGE_JSON="$(curl -sf --retry 2 --retry-delay 2 --max-time 30 \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${REPO}/tags?per_page=100&page=${page}")" + COUNT="$(printf '%s' "$PAGE_JSON" | jq 'length')" + [ "$COUNT" -gt 0 ] || break + ALL_TAGS="${ALL_TAGS}$(printf '%s' "$PAGE_JSON" | jq -r '.[].name')"$'\n' + [ "$COUNT" -lt 100 ] && break + page=$((page + 1)) + done + # Version-sort descending and take the tag immediately below VERSION. + PREV="$(printf '%s' "$ALL_TAGS" | grep -v '^$' | grep -vFx "$VERSION" \ + | { printf '%s\n' "$VERSION"; cat; } | sort -rV \ + | awk -v v="$VERSION" 'found { print; exit } $0 == v { found = 1 }')" || true [ -n "$PREV" ] || { echo "::error::Could not resolve previous tag — pass it explicitly"; exit 1; } echo "Previous tag resolved: $PREV" fi @@ -179,20 +199,20 @@ runs: API="https://api.github.com/repos/${REPO}" echo "Fetching compare ${PREVIOUS} -> ${VERSION} from ${REPO}..." - COMPARE="$(curl -sf --max-time 60 \ + COMPARE="$(curl -sf --retry 2 --retry-delay 2 --max-time 60 \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ "${API}/compare/${PREVIOUS}...${VERSION}")" REVISION="$(printf '%s' "$COMPARE" | jq -r 'if (.commits | length) > 0 then .commits[-1].sha[0:12] else "" end')" if [ -z "$REVISION" ]; then - REVISION="$(curl -sf --max-time 30 \ + REVISION="$(curl -sf --retry 2 --retry-delay 2 --max-time 30 \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ "${API}/git/ref/tags/${VERSION}" | jq -r '.object.sha[0:12]')" fi - PREV_SHA="$(curl -sf --max-time 30 \ + PREV_SHA="$(curl -sf --retry 2 --retry-delay 2 --max-time 30 \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ "${API}/git/ref/tags/${PREVIOUS}" | jq -r '.object.sha[0:12]')" @@ -201,7 +221,7 @@ runs: echo "Revision: $REVISION | Previous: $PREV_SHA | Files changed: $FILES" echo "Fetching diff (cap ${MAX_DIFF_BYTES} bytes)..." - curl -sf --max-time 120 \ + curl -sf --retry 2 --retry-delay 2 --max-time 120 \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github.diff" \ "${API}/compare/${PREVIOUS}...${VERSION}" -o diff.raw @@ -245,18 +265,6 @@ runs: '{app: $app, env: $env, repository: $repo, version: $version, revision: $revision, previous: $previous, diff: $diff} + (if $target_env == "" then {} else {target_env: $target_env} end)' > payload.json - # ----------------- Health Check ----------------- - - name: Health check controller - shell: bash - env: - CONTROLLER_URL: ${{ inputs.controller-url }} - run: | - set -euo pipefail - echo "Checking controller at ${CONTROLLER_URL}..." - HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "${CONTROLLER_URL}/healthz" || true)" - [ "$HTTP_CODE" = "200" ] || { echo "::error::Controller not reachable ($HTTP_CODE). Is the runner on Tailscale?"; exit 1; } - echo "Controller is up" - # ----------------- Dry Run ----------------- - name: Dry run summary if: inputs.dry-run == 'true'