diff --git a/.github/actions/cve-scan/README.md b/.github/actions/cve-scan/README.md
index e887c191..39b600a9 100644
--- a/.github/actions/cve-scan/README.md
+++ b/.github/actions/cve-scan/README.md
@@ -14,7 +14,13 @@ If you are wiring this into a release workflow on the advertised advisory-only p
Every outcome writes a Job Summary entry, including the ones that never produce a report — a skipped or inconclusive run is the one most in need of a human noticing.
-**Slack only fires on `schedule` and `release` events.** A `workflow_dispatch` or PR run writes the Job Summary but never posts, so testing the action can't spam the channel — the same restriction `govulncheck` applies, for the same reason. A channel that alerts every time an engineer pokes an action is a channel people stop reading.
+**Slack fires only on the events in `notify-events`, which defaults to `schedule,release`.** So out of the box a `workflow_dispatch` or PR run writes the Job Summary but never posts, and testing the action can't spam the channel — the same restriction `govulncheck` applies, for the same reason. A channel that alerts every time an engineer pokes an action is a channel people stop reading.
+
+Widen it when a pipeline genuinely needs to post on another trigger: `notify-events: workflow_dispatch`. Two things to know before you do.
+
+A reusable workflow sees the **caller's** event name. A `workflow_call` callee invoked from a workflow that was itself dispatched arrives as `workflow_dispatch`, not as whatever published the release, so allow the caller's trigger rather than the one you have in mind. `vcluster-pro`'s release path is exactly this shape: `release.yaml` is `workflow_dispatch`-only by design, so its pre-release pipeline never sees a `release` event.
+
+The list is matched case- and whitespace-insensitively, and entries match whole, so `release` does not match `prerelease`. An empty value (what `${{ vars.SOME_UNSET_VARIABLE }}` resolves to) warns and falls back to the default rather than silently disabling every notification. A blocked event is annotated with a `::notice::` naming the event and the allowlist, so an absent Slack message is always explainable from the log.
Be deliberate about `severity-threshold` on the scheduled sweep. Real images usually carry some `high` findings, so a daily sweep across several images at the default threshold will post most days — and an alert that arrives every day is an alert nobody acts on. Consider `critical` for the schedule and `high` for the release trigger.
@@ -22,21 +28,22 @@ Be deliberate about `severity-threshold` on the scheduled sweep. Real images usu
-| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
-|--------------------|--------|----------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| block-on-findings | string | false | `"false"` | When `true` (or `yes`/`1`/`on`), findings at or
above `severity-threshold` fail the job. When
`false` (default) or unrecognised, findings are
always reported but never fail the
job. A scanner error never fails
the job either way; a config
error always does — see README. |
-| dockerfile-path | string | false | | Optional path to the Dockerfile that
built the image, passed to the
scanner as `--file`. This buys base-image
remediation advice only; it does not
change which vulnerabilities are found, since
application dependencies are scanned by default.
Leave empty when scanning a published
image with no checkout. If set,
it must exist. |
-| enabled | string | false | `"true"` | Kill switch. Only an explicit falsey
value (`false`/`no`/`0`/`off`, any case) skips the scan, and
the skip is annotated rather than
silent. An unrecognised value scans anyway
and warns — resolving toward scanning
so a typo in a repo
variable can't quietly disable a security
control. |
-| image-ref | string | true | | Full registry reference to scan, e.g.
`ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`. |
-| notify | string | false | `"true"` | Send a Slack notification on findings,
a scanner error, or a config
error. Accepts the same tolerant spellings
as `enabled`/`block-on-findings` (`yes`/`1`/`on`, `no`/`0`/`off`, any case); an unrecognised value
still notifies and warns. Only fires
on `schedule` and `release` events —
a `workflow_dispatch` or PR run never notifies,
so testing the action can't spam
the channel. The Job Summary is
written either way. |
-| registry | string | false | `"ghcr.io"` | Registry host to authenticate against, e.g.
`ghcr.io`. Only used when `registry-password` is
set. |
-| registry-password | string | false | | Password or token for the registry
holding `image-ref`. For GHCR pass the
caller's `secrets.GITHUB_TOKEN` with `packages: read`. Masked in
the log before use. |
-| registry-username | string | false | `"${{ github.actor }}"` | Username for the registry holding `image-ref`.
Defaults to the triggering actor, which
is what GHCR expects. GHCR only
checks the token, but docker/login-action requires
a username value to be present
— same constraint promote-release documents. |
-| scanner | string | false | `"snyk"` | Which scanner adapter to run. Selects
`src/scanners/.sh`. The action's inputs/outputs never change
when this does. |
-| scanner-token | string | false | | Credential the selected adapter needs, if
any. Named generically on purpose —
its meaning depends on `scanner`, so swapping
tools repoints the secret a caller
maps in here without renaming this
input. |
-| scanner-version | string | false | `"v1.1306.3"` | Version of the scanner CLI to
install when it isn't already on
the runner. Pinned so results are
reproducible; the adapter installs and checksum-verifies
it. Renovate keeps the default current. |
-| severity-threshold | string | false | `"high"` | Minimum severity that counts toward gating:
`critical`, `high`, `medium`, or `low`. |
-| slack-webhook-url | string | false | | Slack incoming webhook URL for the
ci-test-notify action. Required when `notify: true`. |
+| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
+|--------------------|--------|----------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| block-on-findings | string | false | `"false"` | When `true` (or `yes`/`1`/`on`), findings at or
above `severity-threshold` fail the job. When
`false` (default) or unrecognised, findings are
always reported but never fail the
job. A scanner error never fails
the job either way; a config
error always does — see README. |
+| dockerfile-path | string | false | | Optional path to the Dockerfile that
built the image, passed to the
scanner as `--file`. This buys base-image
remediation advice only; it does not
change which vulnerabilities are found, since
application dependencies are scanned by default.
Leave empty when scanning a published
image with no checkout. If set,
it must exist. |
+| enabled | string | false | `"true"` | Kill switch. Only an explicit falsey
value (`false`/`no`/`0`/`off`, any case) skips the scan, and
the skip is annotated rather than
silent. An unrecognised value scans anyway
and warns — resolving toward scanning
so a typo in a repo
variable can't quietly disable a security
control. |
+| image-ref | string | true | | Full registry reference to scan, e.g.
`ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`. |
+| notify | string | false | `"true"` | Send a Slack notification on findings,
a scanner error, or a config
error. Accepts the same tolerant spellings
as `enabled`/`block-on-findings` (`yes`/`1`/`on`, `no`/`0`/`off`, any case); an unrecognised value
still notifies and warns. Also gated
by `notify-events`, which by default keeps
a `workflow_dispatch` or PR run silent
so testing the action can't spam
the channel. The Job Summary is
written either way. |
+| notify-events | string | false | `"schedule,release"` | Comma-separated allowlist of `github.event_name` values permitted
to notify. Case- and whitespace-insensitive, and
entries match whole, so `release` does
not match `prerelease`. An empty value
warns and falls back to the
default. Note a reusable workflow sees
the *caller's* event name, so a
`workflow_call` callee must allow whatever triggered
the caller. |
+| registry | string | false | `"ghcr.io"` | Registry host to authenticate against, e.g.
`ghcr.io`. Only used when `registry-password` is
set. |
+| registry-password | string | false | | Password or token for the registry
holding `image-ref`. For GHCR pass the
caller's `secrets.GITHUB_TOKEN` with `packages: read`. Masked in
the log before use. |
+| registry-username | string | false | `"${{ github.actor }}"` | Username for the registry holding `image-ref`.
Defaults to the triggering actor, which
is what GHCR expects. GHCR only
checks the token, but docker/login-action requires
a username value to be present
— same constraint promote-release documents. |
+| scanner | string | false | `"snyk"` | Which scanner adapter to run. Selects
`src/scanners/.sh`. The action's inputs/outputs never change
when this does. |
+| scanner-token | string | false | | Credential the selected adapter needs, if
any. Named generically on purpose —
its meaning depends on `scanner`, so swapping
tools repoints the secret a caller
maps in here without renaming this
input. |
+| scanner-version | string | false | `"v1.1306.3"` | Version of the scanner CLI to
install when it isn't already on
the runner. Pinned so results are
reproducible; the adapter installs and checksum-verifies
it. Renovate keeps the default current. |
+| severity-threshold | string | false | `"high"` | Minimum severity that counts toward gating:
`critical`, `high`, `medium`, or `low`. |
+| slack-webhook-url | string | false | | Slack incoming webhook URL for the
ci-test-notify action. Required when `notify: true`. |
@@ -187,6 +194,8 @@ Uploading SARIF to the Security tab (caller's choice — kept out of this action
sarif_file: ${{ steps.scan.outputs.sarif-path }}
```
+When the scanner produced a SARIF, the Slack message links to the Security tab filtered to the ref that was scanned. The link is derived from `github.ref`, the same ref `upload-sarif` defaults to, so the two cannot point at different places. The filter is not optional: the tab shows the default branch otherwise, and an RC scanned from a tag reads as empty. Uploading the SARIF elsewhere, or not at all, leaves the link pointing at a view with nothing in it.
+
## Suppressing a finding
Use Snyk's own [`.snyk` policy file](https://docs.snyk.io/manage-risk/prioritize-issues-for-fixing/ignore-issues). Snyk reads it from the working directory during the scan, so suppressed findings never reach this action and no input is needed:
diff --git a/.github/actions/cve-scan/action.yml b/.github/actions/cve-scan/action.yml
index d2ee34cc..927d0227 100644
--- a/.github/actions/cve-scan/action.yml
+++ b/.github/actions/cve-scan/action.yml
@@ -50,9 +50,13 @@ inputs:
required: false
default: "false"
notify:
- description: "Send a Slack notification on findings, a scanner error, or a config error. Accepts the same tolerant spellings as `enabled`/`block-on-findings` (`yes`/`1`/`on`, `no`/`0`/`off`, any case); an unrecognised value still notifies and warns. Only fires on `schedule` and `release` events — a `workflow_dispatch` or PR run never notifies, so testing the action can't spam the channel. The Job Summary is written either way."
+ description: "Send a Slack notification on findings, a scanner error, or a config error. Accepts the same tolerant spellings as `enabled`/`block-on-findings` (`yes`/`1`/`on`, `no`/`0`/`off`, any case); an unrecognised value still notifies and warns. Also gated by `notify-events`, which by default keeps a `workflow_dispatch` or PR run silent so testing the action can't spam the channel. The Job Summary is written either way."
required: false
default: "true"
+ notify-events:
+ description: "Comma-separated allowlist of `github.event_name` values permitted to notify. Case- and whitespace-insensitive, and entries match whole, so `release` does not match `prerelease`. An empty value warns and falls back to the default. Note a reusable workflow sees the *caller's* event name, so a `workflow_call` callee must allow whatever triggered the caller."
+ required: false
+ default: "schedule,release"
slack-webhook-url:
description: "Slack incoming webhook URL for the ci-test-notify action. Required when `notify: true`."
required: false
@@ -128,19 +132,19 @@ runs:
ENABLED: ${{ inputs.enabled }}
BLOCK_ON_FINDINGS: ${{ inputs.block-on-findings }}
NOTIFY: ${{ inputs.notify }}
+ NOTIFY_EVENTS: ${{ inputs.notify-events }}
run: ${{ github.action_path }}/run.sh
- name: Send Slack notification
# Two gates, for two different reasons.
#
- # Event allowlist: only the two triggers this action is built for can
- # post. A `workflow_dispatch` run is someone testing, and a channel that
+ # notify-effective carries both the tolerant `notify` comparison and the
+ # `notify-events` allowlist, resolved in run.sh where a mismatch can be
+ # annotated. The allowlist defaults to schedule,release: a channel that
# gets a message every time an engineer pokes the action is a channel
- # people learn to ignore — which costs more than the alert is worth.
- # `govulncheck` restricts to `schedule` for the same reason; the release
- # event is added here because the prerelease scan is the case this action
- # exists for. Deliberately an allowlist, not a denylist: a new trigger
- # should have to opt in rather than start posting by surprise.
+ # people learn to ignore. Still an allowlist, not a denylist — a new
+ # trigger opts in by passing notify-events, rather than starting to post
+ # by surprise.
#
# Outcome gate: `outcome == 'failure'` is the third case. A config error
# (bad severity-threshold, a scanner that couldn't
@@ -154,10 +158,12 @@ runs:
# arm a killed scheduled scan produces no page and no Job Summary
# entry — a security scan that silently stopped scanning, which is the
# exact failure this action's error taxonomy exists to prevent.
+ # A cancel that lands before run.sh resolves notify-effective leaves it
+ # unset, so notify-events isn't applied on that path: unresolved gates
+ # resolve toward notifying, same as an unrecognised notify value.
if: >-
always() &&
steps.scan.outputs.notify-effective != 'false' &&
- (github.event_name == 'schedule' || github.event_name == 'release') &&
(steps.scan.outputs.has-vulnerabilities == 'true' ||
steps.scan.outputs.scanner-error == 'true' ||
steps.scan.outcome == 'failure' ||
@@ -177,9 +183,15 @@ runs:
# all, so it can't be distinguished from a config error any other way.
# Otherwise ordered so a blocked scan with findings reads as findings,
# not as a config error — it fails the job too.
+ #
+ # The link is derived, not an input, so it can't drift from the upload:
+ # upload-sarif defaults to the same github.ref. Needs the ref filter or
+ # the page shows the default branch and reads as empty, and sits outside
+ # the fence because Slack won't linkify inside one.
details: |
${{ steps.scan.outcome == 'cancelled' && 'CANCELLED — the scan step was killed (job timeout or a manual cancel) before it could finish; nothing was verified' || (steps.scan.outputs.scanner-error == 'true' && 'SCANNER ERROR — no scan was performed, nothing was verified' || (steps.scan.outputs.has-vulnerabilities == 'true' && 'FINDINGS at or above the severity threshold' || 'CONFIGURATION ERROR — cve-scan could not run. An authoring or provisioning mistake, not a scan result; see the job log.')) }}
```
${{ steps.scan.outputs.summary }}
```
+ ${{ steps.scan.outputs.sarif-path != '' && format('Findings: <{0}/{1}/security/code-scanning?query=ref:{2}|View in Security tab>', github.server_url, github.repository, github.ref) || '' }}
webhook-url: ${{ inputs.slack-webhook-url }}
diff --git a/.github/actions/cve-scan/run.sh b/.github/actions/cve-scan/run.sh
index 7ec9c1ad..893ab87e 100755
--- a/.github/actions/cve-scan/run.sh
+++ b/.github/actions/cve-scan/run.sh
@@ -54,6 +54,34 @@ for flag in ENABLED BLOCK_ON_FINDINGS NOTIFY; do
echo "::warning::cve-scan: unrecognised ${input}='${!flag}' — using the safe default"
done
+# Resolved here rather than in action.yml's if: for the same reason
+# notify-effective exists — YAML can't annotate a mismatch, so "schedule, release"
+# with a space would silence notifications with nothing saying why.
+# Non-colon default: unset takes the default silently, but an explicitly empty
+# value (`${{ vars.UNSET }}`) warns instead of looking like it worked.
+NOTIFY_EVENTS=$(normalize_flag "${NOTIFY_EVENTS-schedule,release}")
+if [ -z "$NOTIFY_EVENTS" ]; then
+ echo "::warning::cve-scan: empty notify-events — using the safe default 'schedule,release'"
+ NOTIFY_EVENTS="schedule,release"
+fi
+
+# Empty only when run outside Actions: no event to test, so leave NOTIFY alone
+# rather than resolving toward silence.
+# `!= false`, not `= true`: to_bool passes an unrecognised value through, so a
+# typo'd `truee` would otherwise skip the gate and still satisfy the caller's
+# `!= 'false'`. Matching the caller's comparison keeps the two gates independent,
+# the way they were when the event check lived in YAML.
+EVENT_NAME=$(normalize_flag "${GITHUB_EVENT_NAME:-}")
+if [ -n "$EVENT_NAME" ] && [ "$NOTIFY" != false ]; then
+ # Comma-wrapped so entries match whole: `release` must not match `prerelease`.
+ if [[ ",${NOTIFY_EVENTS}," != *",${EVENT_NAME},"* ]]; then
+ NOTIFY=false
+ # Notice, not warning: staying silent here is the designed behaviour, but
+ # "why did no Slack message arrive" still needs an answer in the log.
+ echo "::notice::cve-scan: not notifying on event '${EVENT_NAME}' — notify-events allows '${NOTIFY_EVENTS}'"
+ fi
+fi
+
write_output() {
echo "$1=$2" >> "$GITHUB_OUTPUT"
}
@@ -67,7 +95,8 @@ write_output() {
# pure YAML, so nothing could annotate the mismatch. This output exists so the
# caller's if: gets the same tolerant, warned comparison for free, on every
# outcome — scanner-error and config-error included, which is exactly when a
-# notification matters most.
+# notification matters most. The event allowlist folds in here too, so the
+# caller's if: is one comparison rather than two.
write_output notify-effective "$NOTIFY"
# Every outcome leaves a Job Summary entry, including the ones with no report.
diff --git a/.github/actions/cve-scan/test/helpers.bash b/.github/actions/cve-scan/test/helpers.bash
index 868712db..dfcad7d5 100644
--- a/.github/actions/cve-scan/test/helpers.bash
+++ b/.github/actions/cve-scan/test/helpers.bash
@@ -12,6 +12,10 @@ grab_output() {
# The temp dir, the GITHUB_OUTPUT file and the image ref every suite needs.
setup_tmp_env() {
+ # Actions sets GITHUB_EVENT_NAME on every runner, and run.sh reads it for the
+ # notify-events allowlist, so inheriting it makes results depend on where the
+ # suite runs: green locally, red in CI. Tests that care set it themselves.
+ unset GITHUB_EVENT_NAME
TEST_DIR=$(mktemp -d)
export TEST_DIR
export GITHUB_OUTPUT="$TEST_DIR/github_output"
diff --git a/.github/actions/cve-scan/test/run.bats b/.github/actions/cve-scan/test/run.bats
index 5120535c..3bdf90e3 100644
--- a/.github/actions/cve-scan/test/run.bats
+++ b/.github/actions/cve-scan/test/run.bats
@@ -217,7 +217,9 @@ teardown() {
has-vulnerabilities=false
forged=yes" run bash "$SCRIPT"
[ "$status" -eq 1 ]
- [[ "$output" == *"whitespace"* ]]
+ # A newline is a control character, so the control-character guard rejects it
+ # before the whitespace check is reached — same message as its three siblings.
+ [[ "$output" == *"control characters"* ]]
[ "$(grep -c '^forged=' "$GITHUB_OUTPUT" || true)" = "0" ]
}
@@ -547,6 +549,109 @@ EOF
[ "$(grab_output notify-effective)" = "true" ]
}
+# --- notify-events allowlist ---------------------------------------------------------
+# The allowlist lives here rather than in action.yml's if: so a mistyped or
+# whitespaced list annotates instead of silently dropping every notification.
+# These cases are what that move buys: none of them is reachable from YAML.
+
+@test "the default allowlist notifies on schedule and release" {
+ for e in schedule release; do
+ GITHUB_EVENT_NAME="$e" NOTIFY=true run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "true" ] || {
+ echo "event='$e' should have been allowed by the default"
+ return 1
+ }
+ done
+}
+
+@test "the default allowlist stays silent on workflow_dispatch and pull_request" {
+ for e in workflow_dispatch pull_request; do
+ GITHUB_EVENT_NAME="$e" NOTIFY=true run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "false" ] || {
+ echo "event='$e' should not have been allowed by the default"
+ return 1
+ }
+ done
+}
+
+@test "a blocked event says so, so an absent Slack message is explainable" {
+ GITHUB_EVENT_NAME=workflow_dispatch NOTIFY=true run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"not notifying on event 'workflow_dispatch'"* ]]
+}
+
+@test "a custom allowlist opts one event in without opting the defaults in" {
+ GITHUB_EVENT_NAME=workflow_dispatch NOTIFY_EVENTS=workflow_dispatch NOTIFY=true run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "true" ]
+
+ GITHUB_EVENT_NAME=schedule NOTIFY_EVENTS=workflow_dispatch NOTIFY=true run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "false" ]
+}
+
+# The reason the gate is in bash: `${{ }}` can't strip a space or lowercase a
+# word, so "Schedule, Release" typed into a repo variable would match nothing.
+@test "the allowlist tolerates whitespace and case, like the other switches" {
+ GITHUB_EVENT_NAME=release NOTIFY_EVENTS=" Schedule, Release " NOTIFY=true run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "true" ]
+}
+
+@test "allowlist entries match whole, so release does not match prerelease" {
+ GITHUB_EVENT_NAME=prerelease NOTIFY_EVENTS=release NOTIFY=true run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "false" ]
+}
+
+# `notify-events: ${{ vars.UNSET_VARIABLE }}` arrives empty. Reverting to the
+# default silently would look like the caller's widening worked.
+@test "an empty allowlist warns and falls back to the default" {
+ GITHUB_EVENT_NAME=schedule NOTIFY_EVENTS="" NOTIFY=true run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "true" ]
+ [[ "$output" == *"empty notify-events"* ]]
+}
+
+# The two gates have to stay independent. to_bool passes an unrecognised value
+# through, so guarding the allowlist on `= true` would let a typo'd notify skip
+# it and still satisfy the caller's `!= 'false'`.
+@test "an unrecognised notify value still cannot post on a blocked event" {
+ GITHUB_EVENT_NAME=workflow_dispatch NOTIFY=truee run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "false" ]
+ [[ "$output" == *"unrecognised notify"* ]]
+}
+
+@test "an unrecognised notify value still notifies on an allowed event" {
+ GITHUB_EVENT_NAME=schedule NOTIFY=truee run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" != "false" ]
+}
+
+@test "an allowed event cannot re-enable a caller who asked not to notify" {
+ GITHUB_EVENT_NAME=schedule NOTIFY=false run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "false" ]
+}
+
+# Outside Actions there is no event to test. Suppressing on that would make the
+# gate fire on every direct invocation, including this suite's other cases.
+@test "an absent event name leaves notify untouched" {
+ NOTIFY=true run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(grab_output notify-effective)" = "true" ]
+ [[ "$output" != *"not notifying on event"* ]]
+}
+
+@test "the event gate applies on a config-error outcome too" {
+ GITHUB_EVENT_NAME=pull_request NOTIFY=true SCANNER=made-up-tool run bash "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [ "$(grab_output notify-effective)" = "false" ]
+}
+
# --- Input passthrough + output plumbing ----------------------------------------------
@test "passes IMAGE_REF through to process-findings.sh" {
diff --git a/.github/workflows/test-cve-scan.yaml b/.github/workflows/test-cve-scan.yaml
index 2ace2dc5..14a5304b 100644
--- a/.github/workflows/test-cve-scan.yaml
+++ b/.github/workflows/test-cve-scan.yaml
@@ -16,9 +16,17 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+ # This action installs bats, it does not run anything. It has no `tests`
+ # input, and an unknown input is a warning rather than an error, so a job
+ # that only calls it passes without executing a single test.
- uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0
with:
- tests: .github/actions/cve-scan/test
+ support-install: false
+ assert-install: false
+ detik-install: false
+ file-install: false
+ - name: Run cve-scan tests
+ run: bats .github/actions/cve-scan/test/*.bats
# Drives the composite through `uses:` so action.yml's own wiring is exercised.
# The bats suite invokes src/*.sh directly with env exported by hand, so none
diff --git a/README.md b/README.md
index d1fe8c56..cf85ed42 100644
--- a/README.md
+++ b/README.md
@@ -893,7 +893,8 @@ jobs:
- `enabled` (optional, default: `true`): kill switch; resolves toward scanning on an unrecognised value
- `block-on-findings` (optional, default: `false`): advisory vs. blocking posture
- `registry` (optional, default: `ghcr.io`) / `registry-username` / `registry-password`: the action pulls the image with the caller's own credentials rather than relying on the scanner vendor's registry integration
-- `notify` / `slack-webhook-url` (optional, default: `true` / —)
+- `notify` / `slack-webhook-url` (optional, default: `true` / —): gated by `notify-events` as well, so both have to admit a run before it posts
+- `notify-events` (optional, default: `schedule,release`): comma-separated allowlist of `github.event_name` values permitted to notify, so a `workflow_dispatch` or PR run stays silent unless it opts in. A `workflow_call` callee sees the caller's event name
**Outputs:**