feat: add reusable container-CI workflows (lint / security / smoke-test)#141
Conversation
Add hadolint (via hadolint:latest-alpine image to track current releases — the action ships with v2.12.0 which crashes on Docker 25's HEALTHCHECK --start-interval flag) plus optional shellcheck against caller-specified scandirs. Consumers: snipe-it-docker-compose-stack, phpbu-docker — both currently duplicate this exact pattern in repo-local lint workflows. Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
Scan a single already-published image reference with Trivy, SARIF upload to GitHub code-scanning. Caller fans out matrices over tags (latest / rolling / branch tags) — one ref per invocation. Distinct from build-container.yml's build-time scan: this is intended for schedule / workflow_run triggers AFTER push, so it catches CVEs disclosed since the build. Default exit-code 0 (informational) matches the snipe-it pattern where third-party transitive CVEs can't be patched downstream — alert surface is the GitHub Security tab via SARIF. Consumers: snipe-it-docker-compose-stack security.yml (latest+rolling matrix), phpbu-docker security.yml (ci+ci-full matrix). Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
…kflow Build the caller's image locally (amd64, --load), then run container-structure-test against a config file in the caller's repo. Scope is intentionally narrow: only the structure-test surface that's shared across container repos. Consumer-specific orchestration (docker compose up + HTTP probe, init-script idempotency, env bootstrap) stays in the caller's workflow — those depend on app-specific routes, healthcheck semantics, and secret-shaped placeholders that don't generalise. Consumers: snipe-it-docker-compose-stack smoke-test.yml, phpbu-docker test.yml — both currently duplicate the buildx + load + install-cst + run-cst pattern. Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
…w set Add docs/container-workflows.md covering the four container-focused reusable workflows (build-container, lint-container, security-container, smoke-test-container) plus the supporting set (scorecard, ghcr-retention, gitleaks, lint-*, auto-merge-deps). Includes minimal caller patterns and an explicit list of what stays in the caller (compose orchestration, init-script idempotency, osv-scanner against language lockfiles, multi-track build matrices) so contributors don't try to push those into the abstraction. Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
There was a problem hiding this comment.
Code Review
This pull request introduces a new documentation file, docs/container-workflows.md, which outlines reusable GitHub Actions workflows for container repositories. It provides descriptions of workflows for building, linting, and security scanning, along with example caller patterns and organizational conventions. A review comment correctly identified that the security scan example was missing the security-events: write permission, which is necessary for the SARIF upload step to function.
There was a problem hiding this comment.
Pull request overview
This PR introduces a set of reusable GitHub Actions workflows aimed at standardizing and de-duplicating container CI across Netresearch container repositories, complementing the existing build-container.yml reusable.
Changes:
- Adds
lint-container.ymlreusable for Dockerfile linting (hadolint) plus optional shellcheck. - Adds
security-container.ymlreusable for post-publish Trivy scanning with SARIF upload to GitHub code-scanning. - Adds
smoke-test-container.ymlreusable to build a local amd64 image and run container-structure-test, plus documentation for usage patterns.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
docs/container-workflows.md |
Documents the full container workflow suite and provides caller snippets + delineation of caller-owned responsibilities. |
.github/workflows/lint-container.yml |
New reusable workflow for hadolint (via container) and optional shellcheck. |
.github/workflows/security-container.yml |
New reusable workflow for scanning already-published images with Trivy and uploading SARIF. |
.github/workflows/smoke-test-container.yml |
New reusable workflow to locally build amd64 images and run container-structure-test. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… GHCR auth Six review threads from copilot-pull-request-reviewer and gemini-code-assist on PR 141. Five are addressed in this commit; the sixth is a false-positive (separate inline reply with explanation). ADDRESSED (5): 1. security-container.yml — missing `packages: read` permission. Without it, Trivy gets HTTP 401 when scanning a private GHCR image (or an org-locked public package). Added at job level alongside `security-events: write`. 2. security-container.yml — no registry login before Trivy. Trivy spawns its own docker-client and does NOT inherit pre-existing daemon credentials. Added a conditional `docker/login-action` step gated on `startsWith(image-ref, 'ghcr.io/')` so non-GHCR callers (docker.io, internal registries) aren't forced through a bogus GHCR auth. Other-registry callers add their own login in a wrapper. 3. smoke-test-container.yml — container-structure-test was fetched from `https://storage.googleapis.com/.../latest/...` (mutable). Now pinned to v1.22.1 by SHA256 (fa35e89...) fetched from the GitHub release asset, with verification before install. Renovate regex-managers can bump version+sha in tandem. 4. lint-container.yml — hadolint was `hadolint/hadolint:latest-alpine` (mutable tag). Pinned by digest: `:v2.12.0-alpine@sha256:3c206a...`. Bumped via Renovate's docker-tag updater. Design-notes comment block updated to explain the pin. 5. docs/container-workflows.md — caller `permissions:` example was `{ contents: read }` only. Added preamble explaining that `security-container.yml` declares its own job-level permissions (security-events: write, packages: read, contents: read) so the caller's top-level `permissions:` block only needs to cover OTHER jobs. Keeps the documented caller-side minimum at `contents: read`. NOT ADDRESSED (1, push-back via inline reply): 6. lint-container.yml line 135 — Copilot suggested the action version comment `# 2.0.0` should be `# v2.0.0` to "match the established convention". Rejected: `ludeeus/action-shellcheck` upstream uses tags WITHOUT a v prefix (verified via `gh api repos/ludeeus/action-shellcheck/tags`). The SHA pin's comment must match upstream's actual tag string, not a synthetic v-prefixed alias. Reply posted to the review thread. VERIFICATION: - actionlint 1.7.12 clean on all three files. - yamllint exit 0 (only pre-existing warnings about single-space SHA pin comments, which the repo's reusable lint-yaml allows). - All files end with single newline (no trailing blank line). Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
The netresearch/.github reusable lint-container.yml on branch
feat/reusable-container-workflows (HEAD 1a56c99) pins hadolint to
v2.12.0-alpine by digest. That's exactly the version the original
snipe-it lint.yml warned against — it predates Docker 25's
HEALTHCHECK --start-interval flag and crashes on the snipe-it
Dockerfile's healthcheck line:
/dev/stdin:228:5 invalid flag: --start-interval
Process completed with exit code 1.
The reusable's docstring still claims latest-alpine, so this is an
upstream regression between PR review and the current branch HEAD.
Reverting the lint.yml migration until netresearch/.github either:
- re-pins to a hadolint version >= v2.13 (which supports
--start-interval), or
- adds a hadolint-image input so callers can override.
The shellcheck migration is reverted together with hadolint — they
share the same caller-job wrapping.
Refs: netresearch/.github#141
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
The previous commit (1a56c99) pinned hadolint to v2.12.0-alpine in response to Copilot's "mutable tag" review. That digest pins the March 2023 release, which is exactly the broken version the DESIGN NOTES warned about: it crashes with `invalid flag: --start-interval` on any Dockerfile that uses Docker 25's HEALTHCHECK --start-interval= syntax. I assumed `:latest-alpine` and `:v2.12.0-alpine` shared the same hadolint binary (just rebuilt with newer Alpine base). They don't: upstream cut v2.13.1 (Sep 2025) and v2.14.0 (Sep 2025) without updating my memory of the project. The Phase 2 sub-agent caught the regression on the snipe-it caller PR — `lint.yml` failing on snipe-it's HEALTHCHECK --start-interval=5s. Pin corrected to v2.14.0-alpine (@sha256:7aba693c1442eb31c0b015c129697cb3b6cb7da589d85c7562f9deb435a6657c). Comment block extended with a "MUST be >= v2.13" guard so the same mistake doesn't recur on the next Renovate bump. Verified locally against a Dockerfile with the same HEALTHCHECK form as the snipe-it runtime stage: v2.12.0-alpine: /dev/stdin:2:48 invalid flag: --start-interval v2.14.0-alpine: DL3006 warning (unrelated, just a base-image hint) Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
|
PR netresearch/.github#141 merged at 2026-05-21T20:58:39Z with merge commit 7477e5e. Two follow-ups in this commit: 1. smoke-test.yml: ref flip @feat/reusable-container-workflows → @main 2. lint.yml: re-apply the container-lint migration that was reverted in f78c9e5. The revert was needed because the reusable's initial hadolint pin (v2.12.0-alpine) was the version that crashes on HEALTHCHECK --start-interval — exactly the bug the reusable's DESIGN NOTES warned about. The reusable's pin was corrected to v2.14.0 in netresearch/.github commit a4a763e (also part of the merged PR 141), so the migration now works end-to-end. Inline yamllint job stays as-is (the snipe-it repo has no .yamllint.yml so the contract is local — caller-specific, doesn't generalise to the reusable). Verified locally: actionlint clean on both files. CI should pick up the new state on push. TODOs still open (from the Phase 2 sub-agent's report): - build.yml not migrated. build-container.yml needs six additional inputs (custom tags fan-out, build-args, target, OCI labels, per-cell cache-scope, provenance/sbom) before the snipe-it track×composer matrix can call it. Follow-up PR upstream. - security.yml::trivy not migrated. Needs a tolerate-pull-failure step-level input on security-container.yml so callers can keep `continue-on-error: ${{ matrix.tag == 'rolling' }}` semantics (GitHub forbids continue-on-error on reusable-caller jobs). Follow-up PR upstream. - security.yml::osv-scanner stays inline (composer.lock extraction is snipe-it-specific). Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Phase 2 of the container-CI consolidation. Phase 1 reusables (lint-container, security-container, smoke-test-container) are now on `main` in netresearch/.github — PR netresearch/.github#141 merged at 7477e5e. ## What's migrated | Workflow | Before | After | Reusable | |---|---|---|---| | `lint.yml::container-lint` | 87 lines inline | delegates to `lint-container.yml@main` | hadolint v2.14.0 + shellcheck | | `smoke-test.yml::image-surface` | 25 lines inline | delegates to `smoke-test-container.yml@main` | buildx --load + container-structure-test | | `scorecard.yml` | 64 lines local | delegates to `scorecard.yml@main` | OpenSSF Scorecard + SARIF upload | What STAYS in this caller (snipe-it-specific, doesn't generalise): - `lint.yml::compose-validate` — `.env.example` placeholder substitution + `docker compose config --quiet` - `lint.yml::yamllint` — no `.yamllint.yml` config in this repo, rules passed via `config_data` - `smoke-test.yml::{stack-boots-healthy,init.sh-is-idempotent,upstream-tests}` — depend on snipe-it's `.env` shape + HTTP probes - `build.yml` — track×composer matrix + `.snipe-it-version` ref resolution + tag fan-out + `continue-on-error: rolling` - `security.yml::trivy` — needs `continue-on-error: ${{ matrix.tag == 'rolling' }}` which GitHub forbids on reusable-caller jobs - `security.yml::osv-scanner` — composer.lock extraction is snipe-it-specific ## Open dependencies (separate PRs upstream) To migrate `build.yml` to the reusable, `netresearch/.github/.github/workflows/build-container.yml` needs six additional inputs: custom `tags` fan-out, `build-args`, `target`, OCI `labels`, per-cell `cache-scope`, `provenance: mode=max` / `sbom: true`. Tracking as follow-up. To migrate `security.yml::trivy`, `security-container.yml` needs a step-level `tolerate-pull-failure` boolean input so callers can keep rolling-tag-may-not-exist semantics without using job-level `continue-on-error`. Tracking as follow-up. ## Manual action required from a SonarCloud admin The reusable refs use `@main` (org policy: first-party reusables are NOT SHA-pinned). SonarCloud's `githubactions:S7637` rule ("Use full commit SHA hash for this dependency") flags these as 3 LOW security hotspots. Tried SHA-pinning in b38104d; reverted in 5aa57b9 per maintainer feedback ("WIR PINNEN UNSERE EIGENEN RE-USABLE WORKFLOWS NICHT"). These cannot be marked via API without a SonarCloud admin token (not in this repo's secrets). Please mark them as REVIEWED + SAFE in the SonarCloud UI: https://sonarcloud.io/project/security_hotspots?id=netresearch_snipe-it-docker-compose-stack&pullRequest=11 Or deactivate the rule for this project in its Quality Profile. ## Test plan - [x] container-lint / hadolint green (hadolint v2.14.0 handles `HEALTHCHECK --start-interval` correctly) - [x] container-lint / shellcheck green - [x] image-surface / container-structure-test green - [x] scorecard SARIF uploaded - [x] All 6 build matrix cells succeeded (1 expected continue-on-error on tag/rolling) - [ ] SonarCloud hotspots marked SAFE by admin (manual)



Summary
Add three reusable workflows to consolidate the container-CI duplication
across
netresearch/snipe-it-docker-compose-stackandnetresearch/phpbu-docker(and future container repos), filling the gap left by the existing
build-container.yml:lint-container.yml— hadolint (viahadolint:latest-alpineimage so wetrack current releases; the v3.1.0 action ships hadolint v2.12.0 which
crashes on Docker 25's
HEALTHCHECK --start-interval) plus optionalshellcheck against caller-specified scandirs.
security-container.yml— post-build Trivy scan against a single already-published image reference, SARIF upload to GitHub code-scanning. Caller fans
out tag matrices. Distinct from
build-container.yml's build-time scan:this runs on
schedule/workflow_runAFTER push and catches CVEsdisclosed since the build.
smoke-test-container.yml— build locally amd64 +--load+ runcontainer-structure-test against a caller config path.
Plus
docs/container-workflows.mddocumenting the full set(build / lint / security / smoke-test + supporting workflows) with caller
snippets and an explicit "what stays in the caller" list.
Type of Change
Scope (deliberately narrow)
The reusables do NOT cover, by design:
docker compose config/compose uporchestration — needs.env.examplebootstrap with secret-shaped placeholders and known HTTP routes; stays in
caller workflows (snipe-it's
lint.yml::compose-validateandsmoke-test.yml::compose-up).init.shidempotency tests — caller-specific contract.osv-scanneragainst language lockfiles inside images — needs lockfilepath + language; varies per stack.
axes and tag schemes;
build-container.ymlalready supports onerefper call which is the right granularity for fan-out.
Caller impact (not in this PR)
Phase 2 (separate PRs on the consumer repos) will migrate
snipe-it-docker-compose-stack and phpbu-docker to call these reusables.
Expected reduction for snipe-it:
lint.yml(~87 lines → ~20-25 lines,keeping
compose-validateinline),security.ymlTrivy job (~50 lines→ ~15 lines),
smoke-test.yml::image-surfacejob (~25 lines → ~10lines). The track×composer build matrix stays in the caller because
the orchestration logic (ref resolution, tag fan-out,
continue-on-error: rolling) is genuinely snipe-it-specific.Conventions followed
MITheader +Copyright (c) 2026 Netresearch DTT GmbHon every file.# vX.Y.Zcomments(SHAs copied from the existing
build-container.ymland verified currentvia
gh api repos/OWNER/REPO/tags).harden-runneras the first step in every job.permissions:enumerated per job; top-levelcontents: readonly.${{ ... }}interpolation passes throughenv:torun:blocks(SonarCloud S7630).
persist-credentials: falseon every checkout.secrets: inherit— these reusables don't consume any secrets;consumers passing
${{ secrets.GITHUB_TOKEN }}happens at thebuild-container.ymllayer only.Test Plan
actionlintandyamllint(using the same inlineconfig the org
lint-yaml.ymlreusable applies) both clean on allthree new workflow files.
markdownlint-cli2clean ondocs/container-workflows.md.tail -c 4 | xxd -p) — all files end ina single
0a, no0a0a(yamllintempty-linesrule).lint-workflows.yml(actionlint),lint-yaml.yml(yamllint),
lint-markdown.ymlwill be the authoritative gates.end-to-end against real Dockerfiles before we tag a stable ref.