blc-ssh-mcp 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. blc_ssh_mcp-0.6.1/.dockerignore +81 -0
  2. blc_ssh_mcp-0.6.1/.github/workflows/audit.yml +74 -0
  3. blc_ssh_mcp-0.6.1/.github/workflows/ci.yml +344 -0
  4. blc_ssh_mcp-0.6.1/.github/workflows/release.yml +265 -0
  5. blc_ssh_mcp-0.6.1/.gitignore +79 -0
  6. blc_ssh_mcp-0.6.1/.gitleaksignore +53 -0
  7. blc_ssh_mcp-0.6.1/.trivyignore +34 -0
  8. blc_ssh_mcp-0.6.1/AGENTS.md +197 -0
  9. blc_ssh_mcp-0.6.1/CHANGELOG.md +457 -0
  10. blc_ssh_mcp-0.6.1/CLAUDE.md +9 -0
  11. blc_ssh_mcp-0.6.1/CONTRIBUTING.md +67 -0
  12. blc_ssh_mcp-0.6.1/Dockerfile +105 -0
  13. blc_ssh_mcp-0.6.1/LICENSE +373 -0
  14. blc_ssh_mcp-0.6.1/PKG-INFO +41 -0
  15. blc_ssh_mcp-0.6.1/README.md +459 -0
  16. blc_ssh_mcp-0.6.1/SECURITY.md +148 -0
  17. blc_ssh_mcp-0.6.1/compose.yaml +182 -0
  18. blc_ssh_mcp-0.6.1/config/servers.example.toml +105 -0
  19. blc_ssh_mcp-0.6.1/pyproject.toml +154 -0
  20. blc_ssh_mcp-0.6.1/src/ssh_mcp/__init__.py +3 -0
  21. blc_ssh_mcp-0.6.1/src/ssh_mcp/config.py +339 -0
  22. blc_ssh_mcp-0.6.1/src/ssh_mcp/formatting.py +268 -0
  23. blc_ssh_mcp-0.6.1/src/ssh_mcp/healthcheck.py +191 -0
  24. blc_ssh_mcp-0.6.1/src/ssh_mcp/models.py +248 -0
  25. blc_ssh_mcp-0.6.1/src/ssh_mcp/paths.py +338 -0
  26. blc_ssh_mcp-0.6.1/src/ssh_mcp/server.py +1201 -0
  27. blc_ssh_mcp-0.6.1/src/ssh_mcp/ssh.py +2724 -0
  28. blc_ssh_mcp-0.6.1/tests/conftest.py +254 -0
  29. blc_ssh_mcp-0.6.1/tests/test_ci_lint_determinism.py +572 -0
  30. blc_ssh_mcp-0.6.1/tests/test_config.py +403 -0
  31. blc_ssh_mcp-0.6.1/tests/test_dependency_floors.py +241 -0
  32. blc_ssh_mcp-0.6.1/tests/test_formatting.py +427 -0
  33. blc_ssh_mcp-0.6.1/tests/test_healthcheck.py +311 -0
  34. blc_ssh_mcp-0.6.1/tests/test_http_transport.py +1036 -0
  35. blc_ssh_mcp-0.6.1/tests/test_input_limits.py +383 -0
  36. blc_ssh_mcp-0.6.1/tests/test_logging.py +172 -0
  37. blc_ssh_mcp-0.6.1/tests/test_models.py +299 -0
  38. blc_ssh_mcp-0.6.1/tests/test_otel.py +334 -0
  39. blc_ssh_mcp-0.6.1/tests/test_paths.py +353 -0
  40. blc_ssh_mcp-0.6.1/tests/test_server.py +706 -0
  41. blc_ssh_mcp-0.6.1/tests/test_sftp_confinement.py +543 -0
  42. blc_ssh_mcp-0.6.1/tests/test_ssh.py +3873 -0
  43. blc_ssh_mcp-0.6.1/uv.lock +1804 -0
@@ -0,0 +1,81 @@
1
+ # Git and version control
2
+ .git
3
+ .gitignore
4
+ .gitattributes
5
+
6
+ # Python virtual environments and caches
7
+ .venv
8
+ venv
9
+ ENV
10
+ env
11
+ __pycache__
12
+ *.py[cod]
13
+ *$py.class
14
+ .Python
15
+ *.egg-info/
16
+ dist/
17
+ build/
18
+
19
+ # Testing and coverage
20
+ tests/
21
+ pytest.ini
22
+ .pytest_cache/
23
+ .coverage
24
+ htmlcov/
25
+ .tox/
26
+
27
+ # IDE and editor files
28
+ .vscode/
29
+ .idea/
30
+ *.swp
31
+ *.swo
32
+ *~
33
+ .DS_Store
34
+ .sublime-project
35
+ .sublime-workspace
36
+
37
+ # Development tools
38
+ .claude/
39
+ .claude_code/
40
+
41
+ # CI/CD
42
+ .github/
43
+ .gitlab-ci.yml
44
+ .travis.yml
45
+ azure-pipelines.yml
46
+
47
+ # Documentation and changelog
48
+ docs/
49
+ CHANGELOG.md
50
+ CONTRIBUTING.md
51
+ SECURITY.md
52
+ PLAN.md
53
+
54
+ # Logs and temporary files
55
+ logs/
56
+ *.log
57
+ *.log.*
58
+ tmp/
59
+ temp/
60
+
61
+ # Build artifacts
62
+ *.tar.gz
63
+ *.zip
64
+ *.whl
65
+
66
+ # Dependencies and lock files (handled by uv during build)
67
+ # Note: pyproject.toml and uv.lock ARE copied and used
68
+ node_modules/
69
+ npm-debug.log
70
+ yarn-error.log
71
+
72
+ # SSH and sensitive files (use runtime volumes instead)
73
+ .ssh/
74
+ *.pem
75
+ *.key
76
+ config/servers.toml
77
+
78
+ # Miscellaneous
79
+ .env
80
+ .env.local
81
+ .env.*.local
@@ -0,0 +1,74 @@
1
+ name: Scheduled Audit
2
+
3
+ # Proactive advisory discovery, decoupled from PR activity.
4
+ #
5
+ # `pip-audit`'s input is the live OSV/PyPI advisory database, so its result is
6
+ # a function of world-state rather than of any commit. Running it ONLY on
7
+ # push/pull_request means a newly published CVE first surfaces as a red build
8
+ # on whatever unrelated PR happens to be open — which is how a Dependabot
9
+ # lockfile bump (PR #45) ended up blocked by an unrelated `click` advisory.
10
+ #
11
+ # This workflow adds a daily run against the default branch so new advisories
12
+ # get discovered on their own schedule and can be remediated in a dedicated
13
+ # PR. It does NOT replace either of the two commit-triggered `audit` jobs:
14
+ #
15
+ # * ci.yml's `audit` runs on pushes to `main`, on `v*` tags, and on PRs
16
+ # targeting `main` — note that a push to a feature branch gets no audit at
17
+ # all — and unconditionally gates the GHCR image publish via
18
+ # `docker: needs: [test, lint, audit]`.
19
+ # * release.yml's `audit` runs on `v*` tags only and unconditionally gates
20
+ # the PyPI publish via `build: needs: [test, lint, audit]`; it is the audit
21
+ # that guards the release path.
22
+ #
23
+ # Note that blocking a PR *merge* on ci.yml's job additionally requires
24
+ # `Dependency audit` to be listed in the repository's branch-protection
25
+ # required checks — that is a settings change, not something either workflow
26
+ # file can enforce. (release.yml deliberately names its audit job
27
+ # `Dependency audit` as well, so the two are indistinguishable by name.)
28
+ #
29
+ # Caveat on the "proactive" claim: this workflow's only failure signal is a red
30
+ # scheduled run on `main` — there is no notification step and no
31
+ # .github/dependabot.yml — and GitHub disables `schedule:` triggers after 60
32
+ # days of repository inactivity, silently, so on a quiet repo the daily gate
33
+ # stops running with no signal at all.
34
+ on:
35
+ schedule:
36
+ # 06:00 UTC daily. Cron in GitHub Actions is best-effort, not exact.
37
+ - cron: "0 6 * * *"
38
+ workflow_dispatch:
39
+
40
+ # Scheduled runs always use the default branch, so a `workflow_dispatch` on
41
+ # `main` shares this group and cancels an in-flight scheduled audit. Harmless —
42
+ # both runs do exactly the same thing.
43
+ concurrency:
44
+ group: ${{ github.workflow }}-${{ github.ref }}
45
+ cancel-in-progress: true
46
+
47
+ permissions: {}
48
+
49
+ jobs:
50
+ audit:
51
+ name: Scheduled dependency audit
52
+ runs-on: ubuntu-latest
53
+ permissions:
54
+ contents: read
55
+
56
+ steps:
57
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
58
+ with:
59
+ persist-credentials: false
60
+
61
+ - name: Install uv
62
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
63
+ with:
64
+ version: "0.11.32"
65
+ enable-cache: true
66
+
67
+ - name: Install Python
68
+ run: uv python install 3.13
69
+
70
+ - name: Install dependencies
71
+ run: uv sync --locked --extra dev
72
+
73
+ - name: Audit dependencies
74
+ run: uv run pip-audit
@@ -0,0 +1,344 @@
1
+ name: CI
2
+
3
+ # Branches AND `v*` tags. The tag path is not a dry run: `PUBLISH` (docker job)
4
+ # is true for it, so a tag push runs the full matrix and publishes a GHCR image
5
+ # — concurrently with release.yml, which re-runs its own copies of test/lint/
6
+ # audit on the same tag. release.yml's header comment and
7
+ # tests/test_ci_lint_determinism.py both still describe ci.yml as branches-only.
8
+ on:
9
+ push:
10
+ branches: [main]
11
+ tags: ["v*"]
12
+ pull_request:
13
+ branches: [main]
14
+
15
+ concurrency:
16
+ group: ${{ github.workflow }}-${{ github.ref }}
17
+ # Unlike release.yml (`cancel-in-progress: false`, "never cancel a
18
+ # half-finished publish"), this cancels in flight — on the tag and main paths
19
+ # too, where the docker job is publishing.
20
+ cancel-in-progress: true
21
+
22
+ permissions: {}
23
+
24
+ jobs:
25
+ test:
26
+ name: Test (Python ${{ matrix.python-version }})
27
+ runs-on: ubuntu-latest
28
+ permissions:
29
+ contents: read
30
+ strategy:
31
+ fail-fast: false
32
+ matrix:
33
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
34
+
35
+ steps:
36
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
37
+ with:
38
+ persist-credentials: false
39
+
40
+ - name: Install uv
41
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
42
+ with:
43
+ version: "0.11.32"
44
+ enable-cache: true
45
+
46
+ - name: Install Python ${{ matrix.python-version }}
47
+ run: uv python install ${{ matrix.python-version }}
48
+
49
+ - name: Install dependencies
50
+ run: uv sync --locked --extra dev
51
+
52
+ - name: Run tests
53
+ run: uv run python -m pytest tests/ -v --cov=ssh_mcp --cov-report=term-missing
54
+
55
+ lint:
56
+ name: Lint
57
+ runs-on: ubuntu-latest
58
+ permissions:
59
+ contents: read
60
+
61
+ steps:
62
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
63
+ with:
64
+ persist-credentials: false
65
+
66
+ - name: Install uv
67
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
68
+ with:
69
+ version: "0.11.32"
70
+ enable-cache: true
71
+
72
+ - name: Install Python
73
+ run: uv python install 3.13
74
+
75
+ - name: Install dependencies
76
+ run: uv sync --locked --extra dev
77
+
78
+ # ruff and bandit come from the LOCKED dev env at a pinned version.
79
+ # `uvx` was used here previously; it floats to the newest release, so
80
+ # ruff 0.16.0's default-rule expansion (59 -> 413 on 2026-07-23) turned
81
+ # this job red on commits it had passed days earlier. See pyproject.toml
82
+ # [tool.ruff.lint] for the explicitly declared rule set.
83
+ #
84
+ # mypy also gates this job (below) but is NOT exactly pinned
85
+ # (`mypy>=1.15` in pyproject.toml); its determinism rests on uv.lock
86
+ # alone, not on the pin rationale above. Same blind spot in
87
+ # PINNED_LINT_TOOLS in tests/test_ci_lint_determinism.py.
88
+ - name: Check formatting with ruff
89
+ run: uv run ruff format --check src/ tests/
90
+
91
+ - name: Lint with ruff
92
+ run: uv run ruff check src/ tests/
93
+
94
+ - name: Type check with mypy
95
+ run: uv run mypy src/
96
+
97
+ - name: Security scan with bandit
98
+ run: uv run bandit -r src/ -q
99
+
100
+ # `pip-audit` lives in its own job rather than inside `lint` because its
101
+ # input is the LIVE advisory database, not the commit: a newly published
102
+ # CVE against any transitive dependency fails this job on every open PR at
103
+ # once. Keeping it separate makes that failure attributable instead of
104
+ # masquerading as a lint error.
105
+ #
106
+ # Two different guarantees, do not conflate them:
107
+ # * The GHCR image publish is gated UNCONDITIONALLY — `docker` lists this
108
+ # job in its `needs:` (see below), enforced by this workflow itself.
109
+ # * Blocking a PR merge is enforced by BRANCH PROTECTION, not by this
110
+ # file. Until `Dependency audit` is in the repository's required-checks
111
+ # list, this job runs on every PR but does not block merging.
112
+ #
113
+ # A scheduled counterpart runs daily in audit.yml.
114
+ audit:
115
+ name: Dependency audit
116
+ runs-on: ubuntu-latest
117
+ permissions:
118
+ contents: read
119
+
120
+ steps:
121
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
122
+ with:
123
+ persist-credentials: false
124
+
125
+ - name: Install uv
126
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
127
+ with:
128
+ version: "0.11.32"
129
+ enable-cache: true
130
+
131
+ - name: Install Python
132
+ run: uv python install 3.13
133
+
134
+ - name: Install dependencies
135
+ run: uv sync --locked --extra dev
136
+
137
+ - name: Audit dependencies
138
+ run: uv run pip-audit
139
+
140
+ docker:
141
+ name: Docker Build & Push
142
+ runs-on: ubuntu-latest
143
+ # `audit` is REQUIRED here. When pip-audit lived inside `lint` this
144
+ # dependency existed only implicitly; making it explicit is what keeps a
145
+ # known-vulnerable image from reaching GHCR. Do not remove it — a
146
+ # regression test asserts this list contains `audit`.
147
+ needs: [test, lint, audit]
148
+ permissions:
149
+ contents: read
150
+ packages: write
151
+ id-token: write # sign the provenance attestation
152
+ attestations: write # record it
153
+ env:
154
+ # Single source of truth for the PUBLISH path: build, scan, promote and
155
+ # attest all derive their reference from it, so those four steps cannot
156
+ # drift apart. The PR path deliberately stays out of the registry and
157
+ # hardcodes `ssh-mcp:ci` in its build and scan steps instead.
158
+ IMAGE: ghcr.io/${{ github.repository }}
159
+ # Publishing is the exception, not the default. PRs build + scan only.
160
+ PUBLISH: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
161
+ steps:
162
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
163
+ with:
164
+ persist-credentials: false
165
+
166
+ - name: Set up Docker Buildx
167
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
168
+
169
+ - name: Log in to GHCR
170
+ if: env.PUBLISH == 'true'
171
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
172
+ with:
173
+ registry: ghcr.io
174
+ username: ${{ github.actor }}
175
+ password: ${{ secrets.GITHUB_TOKEN }}
176
+
177
+ # Only this step's `tags` output is consumed (by promote + verify). Its
178
+ # `labels`/`annotations` outputs are computed and then used by nothing —
179
+ # the publish build passes no `labels:`, so published images carry none.
180
+ - name: Docker meta
181
+ id: meta
182
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
183
+ with:
184
+ images: ${{ env.IMAGE }}
185
+ tags: |
186
+ type=sha
187
+ type=semver,pattern={{version}}
188
+ type=semver,pattern={{major}}.{{minor}}
189
+ type=raw,value=latest,enable={{is_default_branch}}
190
+
191
+ # -----------------------------------------------------------------------
192
+ # ONE build per path. On the publish path it goes straight to GHCR *by
193
+ # digest* — no tag is attached, so nothing in the registry is reachable by
194
+ # name until Trivy's CRITICAL gate has passed. PRs take the second step
195
+ # below, which loads the image locally instead. The two are independent
196
+ # BuildKit invocations with different exporters, NOT the same build — see
197
+ # test_docker_job_builds_exactly_once in
198
+ # tests/test_ci_lint_determinism.py for why that distinction matters.
199
+ #
200
+ # On the publish step provenance/sbom are disabled on purpose: BuildKit's
201
+ # inline attestations wrap the image in an index, which complicates
202
+ # digest promotion, and GHCR's Referrers API support for them is
203
+ # incomplete. GitHub Artifact Attestations (below) are meant to cover the
204
+ # same ground with a working verifier — but see the verify step: that
205
+ # attest step does not currently run. The PR step sets neither input; it
206
+ # never leaves the runner. (The CycloneDX SBOM is a release.yml artifact,
207
+ # not something this job produces or attests.)
208
+ # -----------------------------------------------------------------------
209
+ - name: Build image (publish path — push by digest, no tags)
210
+ id: build
211
+ if: env.PUBLISH == 'true'
212
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
213
+ with:
214
+ context: .
215
+ outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
216
+ provenance: false
217
+ sbom: false
218
+ cache-from: type=gha
219
+ cache-to: type=gha,mode=max
220
+
221
+ - name: Build image (PR path — local only)
222
+ id: build_local
223
+ if: env.PUBLISH != 'true'
224
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
225
+ with:
226
+ context: .
227
+ push: false
228
+ load: true
229
+ tags: ssh-mcp:ci
230
+ cache-from: type=gha
231
+ cache-to: type=gha,mode=max
232
+
233
+ # -----------------------------------------------------------------------
234
+ # Scan the EXACT digest that was pushed. `${IMAGE}@sha256:…` is content-
235
+ # addressed: there is no tag in the reference, so nothing can have been
236
+ # substituted between the build step and this one.
237
+ #
238
+ # Scope of the gate, deliberately narrow: `severity: CRITICAL` only, so
239
+ # HIGH image findings never fail the build, and `ignore-unfixed` is unset.
240
+ # Open posture decision, tracked in docs/fixes/05-followups.md §3.
241
+ # -----------------------------------------------------------------------
242
+ - name: Scan published digest with Trivy
243
+ if: env.PUBLISH == 'true'
244
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
245
+ with:
246
+ image-ref: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
247
+ severity: CRITICAL
248
+ exit-code: 1
249
+ trivyignores: .trivyignore
250
+
251
+ - name: Scan local image with Trivy
252
+ if: env.PUBLISH != 'true'
253
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
254
+ with:
255
+ image-ref: ssh-mcp:ci
256
+ severity: CRITICAL
257
+ exit-code: 1
258
+ trivyignores: .trivyignore
259
+
260
+ # -----------------------------------------------------------------------
261
+ # Only now does a human-readable tag come into existence, and it is
262
+ # created FROM the scanned digest. No rebuild, no second BuildKit run.
263
+ # Mind the ordering: this step publishes the tags, and the verify step
264
+ # below only reports on them afterwards — a failing verify untags nothing.
265
+ # -----------------------------------------------------------------------
266
+ - name: Promote scanned digest to tags
267
+ if: env.PUBLISH == 'true'
268
+ env:
269
+ TAGS: ${{ steps.meta.outputs.tags }}
270
+ DIGEST: ${{ steps.build.outputs.digest }}
271
+ run: |
272
+ set -euo pipefail
273
+ TAG_ARGS=()
274
+ while IFS= read -r tag; do
275
+ [ -n "$tag" ] && TAG_ARGS+=(--tag "$tag")
276
+ done <<< "$TAGS"
277
+ docker buildx imagetools create "${TAG_ARGS[@]}" "${IMAGE}@${DIGEST}"
278
+
279
+ # Proof, in the job log, that every published tag serves the bytes Trivy
280
+ # cleared — nothing more.
281
+ #
282
+ # Production incident 2026-07-26: the original check compared
283
+ # `sha256sum` of `imagetools inspect --raw` against the build digest and
284
+ # failed on EVERY publish run, taking the attest step below down with it
285
+ # (images published between 2026-07-26 and this fix carry no provenance
286
+ # attestation). Confirmed cause: `imagetools create` does NOT reuse the
287
+ # source digest — it wraps the single-platform manifest in a NEW OCI
288
+ # index, so the tag's own digest legitimately differs from the scanned
289
+ # manifest's. Verified against the live registry: `:latest` was index
290
+ # sha256:8204263e… whose sole child was the scanned sha256:12db37ce…,
291
+ # and re-hashing those bytes reproduces 8204263e exactly.
292
+ # (Piping `inspect --raw` into `sha256sum` is separately fragile if the
293
+ # CLI appends a newline — not the cause here, but a reason not to go back
294
+ # to hashing stdout.)
295
+ #
296
+ # So compare the right thing: the set of image manifests the tag actually
297
+ # serves must be exactly {scanned digest}. Attestation manifests that
298
+ # buildx/attest may attach are excluded — they are metadata about the
299
+ # image, not another runnable image. Do not "simplify" this back to
300
+ # comparing the tag's own digest; that asserts an invariant the promote
301
+ # step deliberately does not hold.
302
+ - name: Verify every tag serves only the scanned digest
303
+ if: env.PUBLISH == 'true'
304
+ env:
305
+ TAGS: ${{ steps.meta.outputs.tags }}
306
+ DIGEST: ${{ steps.build.outputs.digest }}
307
+ run: |
308
+ set -euo pipefail
309
+ while IFS= read -r tag; do
310
+ [ -z "$tag" ] && continue
311
+ raw="$(docker buildx imagetools inspect --raw "$tag")"
312
+ # An index lists children under .manifests; a bare manifest has none.
313
+ served="$(printf '%s' "$raw" | jq -r '
314
+ if .manifests then
315
+ [ .manifests[]
316
+ | select((.annotations["vnd.docker.reference.type"] // "")
317
+ != "attestation-manifest")
318
+ | .digest ] | join(" ")
319
+ else
320
+ empty
321
+ end')"
322
+ if [ -z "$served" ]; then
323
+ served="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$tag")"
324
+ fi
325
+ echo "${tag} serves: ${served} (scanned ${DIGEST})"
326
+ for d in $served; do
327
+ if [ "$d" != "${DIGEST}" ]; then
328
+ echo "::error::${tag} serves ${d}, which is not the scanned digest ${DIGEST}" >&2
329
+ exit 1
330
+ fi
331
+ done
332
+ if [ -z "$served" ]; then
333
+ echo "::error::${tag} resolved to no image manifest at all" >&2
334
+ exit 1
335
+ fi
336
+ done <<< "$TAGS"
337
+
338
+ - name: Attest build provenance for the image
339
+ if: env.PUBLISH == 'true'
340
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
341
+ with:
342
+ subject-name: ${{ env.IMAGE }} # no tag — the digest identifies the subject
343
+ subject-digest: ${{ steps.build.outputs.digest }}
344
+ push-to-registry: true