Compare commits

...
20 Commits
Author SHA1 Message Date
dresberandClaude Sonnet 5 602ff49f7b fix(notifications): use the run's actual API event, not gitea.event_name
notifications.yml is invoked via workflow_call from the caller
workflow, so inside this job gitea.event_name reports "workflow_call"
rather than the top-level event (push/schedule/...) that actually
started the run. Comparing that against the Actions API's per-run
event field (which reports the real trigger) never matched, so
previous_conclusion always stayed "unknown" and healed notifications
never fired. Now the current run's real event is captured from the API
response itself and used for the comparison, falling back to
gitea.event_name only if the current run isn't found in the scanned
history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 21:08:25 +00:00
dresberandClaude Sonnet 5 467e60627b fix(python-checks): don't fail private-index probe on expected 404 at bare /simple/ root
Gitea's PyPI registry only resolves /simple/<package>/, not the bare
/simple/ index root, so the v1.11.2 probe added to give visible
diagnostics for the private-index setup step was always 404ing even
when the index and credentials were correct. This broke installs of
private Python packages during builds. The probe now only fails hard
on 401/403 (auth rejected) or an unreachable host; a plain 404 is
logged and left to pip's own resolution to confirm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:49:00 +00:00
dresber a7d2e552af fix private pip installation and notification of healed build 2026-09-03 21:03:12 +00:00
dresber f01c90f7c6 fix buildx fallback pin 2026-08-20 10:26:16 +02:00
dresber 9ebc572a9c switch to updated trivy version and pipeline image 2026-08-20 09:01:18 +02:00
dresber bd92604912 fix trivy vulnerability DB update outage 2026-08-13 14:45:44 +02:00
dresber a0a75210a3 switch to new image and update trivy checks 2026-07-25 21:55:15 +02:00
dresber a9f75d66fa add multi arch support 2026-07-25 10:46:59 +02:00
dresber 49f2277ea1 add passing secrets to docker build 2026-07-20 23:03:08 +02:00
dresber 06b4c4118c add python update in workflows 2026-07-20 22:40:29 +02:00
dresber 444b5efd39 add feature to use extra index URL 2026-07-20 19:41:26 +02:00
dresber 83911b842f implement an edge tag and update image security check accordingly 2026-06-14 09:51:36 +02:00
dresber f92d92355a add docker image scanner 2026-06-12 15:10:21 +02:00
dresberandClaude Sonnet 4.6 364171ebca fix(python-security-checks): use custom runner image instead of python slim
python:3.14-slim has no Node.js so actions/checkout@v4 fails with
'node: executable file not found in PATH'. Switch to the same
gitea_runner_python314 custom image used by python-checks.yml which
has both Python 3.14 and Node.js. Drop the python_version input as it
no longer drives the container selection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:43:38 +02:00
dresberandClaude Sonnet 4.6 2be1150eec feat: add python-security-checks reusable workflow
Dedicated security-only workflow using python:VERSION-slim.
Runs Bandit (or any security tool) without pytest or coverage.
Supports python_version, install_command, security_command,
and working_directory inputs with sensible defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 20:43:00 +02:00
dresberandClaude Sonnet 4.6 1434f75112 fix: fetch commit subject via Gitea API instead of git log
Notification job had no checkout step so git log always failed,
producing "Commit info unavailable". Now uses the existing
API_GITEA_TOKEN and gitea.sha context to fetch the commit message
from the Gitea API directly.

Also raises default coverage threshold in python-checks to 80%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:01:55 +02:00
dresber 9cba668088 support other project structure for app folders 2026-05-18 22:06:51 +02:00
dresber 53e4b246a4 add node checks as workflow 2026-05-10 20:38:24 +02:00
dresber 87c64c424f correct env variable name 2026-05-10 20:22:57 +02:00
dresber 8266220f12 adopt coverage upload to avoid problems 2026-05-10 19:54:45 +02:00
7 changed files with 632 additions and 136 deletions
+188 -67
View File
@@ -1,5 +1,15 @@
name: Reusable Docker Publish name: Reusable Docker Publish
# Multi-arch (v1.9.0+): builds a manifest list for `platforms` (amd64 + arm64 by default) with
# `docker buildx`, so the same tag runs on x86 servers and on the arm64 Raspberry Pi nodes with no
# per-host changes. The Trivy gate is preserved by building the native amd64 image first with
# --load, scanning that locally, and only then building + pushing the multi-arch manifest (the
# amd64 layers are reused from cache, so only arm64 is newly built).
#
# Runner requirements (baked into the PipelineImage runner; the workflow also self-installs as a
# fallback): the `docker buildx` plugin, and a build daemon that permits `--privileged` containers
# so QEMU/binfmt can be registered for cross-building arm64 on an amd64 runner.
on: on:
workflow_call: workflow_call:
inputs: inputs:
@@ -9,6 +19,29 @@ on:
dockerfile_path: dockerfile_path:
type: string type: string
default: "." default: "."
floating_tag:
type: string
default: "edge"
platforms:
# target platforms for the manifest list. Override to a single platform (e.g.
# "linux/amd64") for images that do not need to run on the arm64 nodes.
type: string
default: "linux/amd64,linux/arm64"
run_image_scan:
type: boolean
default: true
scan_severity:
type: string
default: "HIGH,CRITICAL"
use_private_index:
# pass the private Gitea PyPI index into the build as a BuildKit secret, for images
# whose Dockerfile installs the in-house BB* packages. The Dockerfile must consume it
# via `RUN --mount=type=secret,id=pip_extra_index`, never as an ARG or ENV, so the
# token is not baked into an image layer.
# Gitea 1.27.x does not reliably propagate boolean inputs through
# reusable workflows. Callers pass the literal string "true".
type: string
default: "false"
secrets: secrets:
REGISTRY_USERNAME: { required: true } REGISTRY_USERNAME: { required: true }
REGISTRY_PASSWORD: { required: true } REGISTRY_PASSWORD: { required: true }
@@ -16,12 +49,24 @@ on:
NTFY_TOPIC: { required: true } NTFY_TOPIC: { required: true }
NTFY_TOKEN: { required: true } NTFY_TOKEN: { required: true }
NTFY_SERVER: { required: true } NTFY_SERVER: { required: true }
PIP_EXTRA_INDEX_URL: { required: false }
# Pinned build tooling. Keep in sync with the ARGs in the PipelineImage Dockerfiles.
# buildx: verify the binary against checksums.txt from the buildx GitHub release before bumping.
# binfmt: pin by digest (tonistiigi/binfmt@sha256:...) for the strongest posture; the version tag
# below is immutable enough for CI and avoids a Docker Hub token dance in the workflow.
env:
BUILDX_VERSION: "0.36.1"
BUILDX_SHA256: "48af8a397ebd60178778bf63611dbcebe5f5e7a9be90eb9147b24b9587455778"
BINFMT_IMAGE: "tonistiigi/binfmt:qemu-v10.2.3"
TRIVY_VERSION: "0.74.0"
TRIVY_SHA256: "2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a"
jobs: jobs:
publish: publish:
runs-on: docker runs-on: docker
container: container:
image: gitea.tech-buddy.at/bitbuddydev/gitea_runner_python314:dev-bda315b82bb23d83065b77d91fedf0e20d9accf1 image: gitea.tech-buddy.at/bitbuddydev/gitea_runner_python314:1.1.0
credentials: credentials:
username: ${{ secrets.REGISTRY_USERNAME }} username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -34,100 +79,176 @@ jobs:
id: vars id: vars
shell: bash shell: bash
run: | run: |
set -euo pipefail
SHA_SHORT="$(git rev-parse --short HEAD)" SHA_SHORT="$(git rev-parse --short HEAD)"
FULL_IMAGE="${{ secrets.DOCKER_REGISTRY }}/${{ inputs.image_name }}" FULL_IMAGE="${{ secrets.DOCKER_REGISTRY }}/${{ inputs.image_name }}"
TAGS="-t ${FULL_IMAGE}:${SHA_SHORT}"
# the SHA tag is always built; it is also the target Trivy scans before any push
PUSH_TAGS="-t ${FULL_IMAGE}:${SHA_SHORT} -t ${FULL_IMAGE}:${{ inputs.floating_tag }}"
if echo "${{ gitea.ref }}" | grep -q '^refs/tags/v'; then if echo "${{ gitea.ref }}" | grep -q '^refs/tags/v'; then
VERSION="${{ gitea.ref_name }}" VERSION="${{ gitea.ref_name }}"
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
MAJOR="$(echo "$VERSION" | cut -d. -f1)" MAJOR="$(echo "$VERSION" | cut -d. -f1)"
MINOR="$(echo "$VERSION" | cut -d. -f1,2)" MINOR="$(echo "$VERSION" | cut -d. -f1,2)"
TAGS="${TAGS} -t ${FULL_IMAGE}:latest -t ${FULL_IMAGE}:${VERSION} -t ${FULL_IMAGE}:${MINOR} -t ${FULL_IMAGE}:${MAJOR}" PUSH_TAGS="${PUSH_TAGS} -t ${FULL_IMAGE}:latest -t ${FULL_IMAGE}:${VERSION} -t ${FULL_IMAGE}:${MINOR} -t ${FULL_IMAGE}:${MAJOR}"
fi fi
echo "docker_tags=${TAGS}" >> "$GITEA_OUTPUT" echo "scan_ref=${FULL_IMAGE}:${SHA_SHORT}" >> "$GITEA_OUTPUT"
echo "push_tags=${PUSH_TAGS}" >> "$GITEA_OUTPUT"
echo "full_image=${FULL_IMAGE}" >> "$GITEA_OUTPUT" echo "full_image=${FULL_IMAGE}" >> "$GITEA_OUTPUT"
- name: Docker Login - name: Docker Login
run: | run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ secrets.DOCKER_REGISTRY }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ secrets.DOCKER_REGISTRY }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build image - name: Set up Docker Buildx and QEMU
shell: bash
env:
PLATFORMS: ${{ inputs.platforms }}
run: | run: |
SHA_SHORT="$(git rev-parse --short HEAD)" set -euo pipefail
FULL_IMAGE="${{ secrets.DOCKER_REGISTRY }}/${{ inputs.image_name }}"
docker build \ # Ensure the buildx plugin. Baked into the runner image (PipelineImage); this fallback
-t "${FULL_IMAGE}:${SHA_SHORT}" \ # covers older runners, mirroring the Trivy fallback below. Pinned + checksum-verified.
${{ inputs.dockerfile_path }} if ! docker buildx version >/dev/null 2>&1; then
echo "=== buildx not in runner image, installing v${BUILDX_VERSION} ==="
mkdir -p /usr/libexec/docker/cli-plugins
curl -sSfLo /usr/libexec/docker/cli-plugins/docker-buildx \
"https://github.com/docker/buildx/releases/download/v${BUILDX_VERSION}/buildx-v${BUILDX_VERSION}.linux-amd64"
echo "${BUILDX_SHA256} /usr/libexec/docker/cli-plugins/docker-buildx" | sha256sum -c -
chmod +x /usr/libexec/docker/cli-plugins/docker-buildx
fi
docker buildx version
if echo "${{ gitea.ref }}" | grep -q '^refs/tags/v'; then # Register QEMU so this amd64 runner can cross-build the non-native platforms. binfmt is
VERSION="${{ gitea.ref_name }}" # a kernel concern, so it cannot live in the runner image and is installed here against
VERSION="${VERSION#v}" # the build daemon (needs --privileged).
MAJOR="$(echo "$VERSION" | cut -d. -f1)" if echo "${PLATFORMS}" | grep -q 'arm'; then
MINOR="$(echo "$VERSION" | cut -d. -f1,2)" echo "=== Registering QEMU emulators (${BINFMT_IMAGE}) ==="
docker run --privileged --rm "${BINFMT_IMAGE}" --install arm64
docker tag "${FULL_IMAGE}:${SHA_SHORT}" "${FULL_IMAGE}:latest"
docker tag "${FULL_IMAGE}:${SHA_SHORT}" "${FULL_IMAGE}:${VERSION}"
docker tag "${FULL_IMAGE}:${SHA_SHORT}" "${FULL_IMAGE}:${MINOR}"
docker tag "${FULL_IMAGE}:${SHA_SHORT}" "${FULL_IMAGE}:${MAJOR}"
fi fi
- name: Push image # A docker-container builder is required for multi-platform output (the default docker
# driver builds a single platform only). Idempotent across re-runs on a warm runner.
docker buildx inspect multiarch >/dev/null 2>&1 || \
docker buildx create --name multiarch --driver docker-container --bootstrap
docker buildx use multiarch
- name: Build image for vulnerability scan
if: ${{ inputs.run_image_scan }}
shell: bash shell: bash
env:
PIP_EXTRA_INDEX_URL: ${{ secrets.PIP_EXTRA_INDEX_URL }}
run: |
set -euo pipefail
SECRET_ARGS=""
if [ "${{ inputs.use_private_index }}" = "true" ]; then
if [ -z "${PIP_EXTRA_INDEX_URL:-}" ]; then
echo "use_private_index=true but the PIP_EXTRA_INDEX_URL secret is empty or not set"
exit 1
fi
SECRET_ARGS="--secret id=pip_extra_index,env=PIP_EXTRA_INDEX_URL"
fi
# native amd64 only, loaded into the local image store so Trivy can scan it before
# anything is pushed. The amd64 result is cached and reused by the multi-arch push.
# CVEs live in the base image and OS/Python packages, which are identical across arches,
# so scanning amd64 is a faithful gate for the whole manifest list.
DOCKER_BUILDKIT=1 docker buildx build \
--load \
--platform linux/amd64 \
${SECRET_ARGS} \
-t "${{ steps.vars.outputs.scan_ref }}" \
${{ inputs.dockerfile_path }}
- name: Image Vulnerability Scan
if: ${{ inputs.run_image_scan }}
shell: bash
run: |
set -euo pipefail
# Fallback for runner images without Trivy baked in. Pinned version +
# checksum, never "latest": Trivy releases were compromised in the
# March 2026 supply-chain incident (malicious v0.69.4). Keep in sync
# with the ARGs in the PipelineImage Dockerfiles.
if ! command -v trivy >/dev/null 2>&1; then
echo "=== Trivy not in runner image, installing v${TRIVY_VERSION} ==="
curl -sSfLO "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
echo "${TRIVY_SHA256} trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" | sha256sum -c -
tar -xzf "trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" -C /usr/local/bin trivy
rm "trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
fi
trivy --version
# Trivy's default vulnerability-DB source (mirror.gcr.io/aquasec/trivy-db) is a
# Google-hosted cache of the upstream GHCR image, used to dodge GHCR's unauthenticated
# rate limits. That cache occasionally 404s on a specific layer digest (a known,
# recurring upstream issue: aquasecurity/trivy). Point at the canonical GHCR source
# instead, and retry the DB download alone a few times — trivy exits 1 both for "DB
# download failed" and for "vulnerabilities found", so the download is a separate step
# from the scan to keep those two outcomes distinguishable.
export TRIVY_DB_REPOSITORY="ghcr.io/aquasecurity/trivy-db:2"
export TRIVY_JAVA_DB_REPOSITORY="ghcr.io/aquasecurity/trivy-java-db:1"
echo "=== Ensuring vulnerability DB is up to date ==="
db_attempt=1
db_max_attempts=3
until trivy image --download-db-only; do
if [ "$db_attempt" -ge "$db_max_attempts" ]; then
echo "=== Failed to download the Trivy vulnerability DB after ${db_max_attempts} attempts ==="
exit 1
fi
echo "=== DB download failed, retrying (${db_attempt}/${db_max_attempts})... ==="
db_attempt=$((db_attempt + 1))
sleep $((db_attempt * 5))
done
echo "=== Scanning ${{ steps.vars.outputs.scan_ref }} (fail on fixable ${{ inputs.scan_severity }}) ==="
trivy image \
--exit-code 1 \
--severity "${{ inputs.scan_severity }}" \
--ignore-unfixed \
--skip-db-update \
--scanners vuln,secret \
"${{ steps.vars.outputs.scan_ref }}"
- name: Build and push multi-arch image
shell: bash
env:
PIP_EXTRA_INDEX_URL: ${{ secrets.PIP_EXTRA_INDEX_URL }}
run: | run: |
set -euxo pipefail set -euxo pipefail
echo "=== Git / Ref Info ===" echo "=== Git / Ref Info ==="
git rev-parse HEAD git rev-parse HEAD
git rev-parse --short HEAD
echo "gitea.ref=${{ gitea.ref }}" echo "gitea.ref=${{ gitea.ref }}"
echo "gitea.ref_name=${{ gitea.ref_name }}" echo "gitea.ref_name=${{ gitea.ref_name }}"
echo "platforms=${{ inputs.platforms }}"
SHA_SHORT="$(git rev-parse --short HEAD)" SECRET_ARGS=""
FULL_IMAGE="${{ secrets.DOCKER_REGISTRY }}/${{ inputs.image_name }}" if [ "${{ inputs.use_private_index }}" = "true" ]; then
if [ -z "${PIP_EXTRA_INDEX_URL:-}" ]; then
echo "=== Image Info ===" echo "use_private_index=true but the PIP_EXTRA_INDEX_URL secret is empty or not set"
echo "FULL_IMAGE=${FULL_IMAGE}" exit 1
echo "SHA_SHORT=${SHA_SHORT}" fi
echo "Expected image: ${FULL_IMAGE}:${SHA_SHORT}" SECRET_ARGS="--secret id=pip_extra_index,env=PIP_EXTRA_INDEX_URL"
echo "=== Local Docker Images ==="
docker images | grep "${{ inputs.image_name }}" || true
echo "=== Inspect Image ==="
docker image inspect "${FULL_IMAGE}:${SHA_SHORT}" >/dev/null
echo "=== Docker Auth Check ==="
docker info
echo "=== Push SHA tag ==="
docker push "${FULL_IMAGE}:${SHA_SHORT}"
if echo "${{ gitea.ref }}" | grep -q '^refs/tags/v'; then
echo "=== Version tag detected ==="
VERSION="${{ gitea.ref_name }}"
VERSION="${VERSION#v}"
MAJOR="$(echo "$VERSION" | cut -d. -f1)"
MINOR="$(echo "$VERSION" | cut -d. -f1,2)"
echo "VERSION=${VERSION}"
echo "MINOR=${MINOR}"
echo "MAJOR=${MAJOR}"
echo "=== Push latest ==="
docker push "${FULL_IMAGE}:latest"
echo "=== Push version ==="
docker push "${FULL_IMAGE}:${VERSION}"
echo "=== Push minor ==="
docker push "${FULL_IMAGE}:${MINOR}"
echo "=== Push major ==="
docker push "${FULL_IMAGE}:${MAJOR}"
else
echo "=== No version tag detected, only SHA tag pushed ==="
fi fi
# one build produces every platform and pushes a manifest list per tag. The Trivy gate
# above already ran on the amd64 image these layers are reused from, so a scan failure
# aborts the job before this step. On a version tag this also pushes latest/major/minor.
DOCKER_BUILDKIT=1 docker buildx build \
--push \
--platform "${{ inputs.platforms }}" \
${SECRET_ARGS} \
${{ steps.vars.outputs.push_tags }} \
${{ inputs.dockerfile_path }}
- name: Verify pushed manifest
shell: bash
run: |
set -euo pipefail
# confirm the pushed tag really is a multi-arch manifest list
docker buildx imagetools inspect "${{ steps.vars.outputs.scan_ref }}"
@@ -0,0 +1,94 @@
name: Reusable Image Security Checks
on:
workflow_call:
inputs:
image_name:
required: true
type: string
image_tag:
type: string
default: "edge"
scan_severity:
type: string
default: "HIGH,CRITICAL"
secrets:
REGISTRY_USERNAME: { required: true }
REGISTRY_PASSWORD: { required: true }
DOCKER_REGISTRY: { required: true }
jobs:
scan:
runs-on: docker
container:
image: gitea.tech-buddy.at/bitbuddydev/gitea_runner_python314:1.1.0
credentials:
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- name: Docker Login
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ secrets.DOCKER_REGISTRY }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Pull Image
shell: bash
run: |
set -euo pipefail
FULL_IMAGE="${{ secrets.DOCKER_REGISTRY }}/${{ inputs.image_name }}:${{ inputs.image_tag }}"
echo "=== Pulling ${FULL_IMAGE} ==="
docker pull "${FULL_IMAGE}"
- name: Image Vulnerability Scan
shell: bash
run: |
set -euo pipefail
# Fallback for runner images without Trivy baked in. Pinned version +
# checksum, never "latest": Trivy releases were compromised in the
# March 2026 supply-chain incident (malicious v0.69.4). Keep in sync
# with the ARGs in the PipelineImage Dockerfiles.
TRIVY_VERSION=0.74.0
TRIVY_SHA256=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
if ! command -v trivy >/dev/null 2>&1; then
echo "=== Trivy not in runner image, installing v${TRIVY_VERSION} ==="
curl -sSfLO "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
echo "${TRIVY_SHA256} trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" | sha256sum -c -
tar -xzf "trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" -C /usr/local/bin trivy
rm "trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
fi
trivy --version
FULL_IMAGE="${{ secrets.DOCKER_REGISTRY }}/${{ inputs.image_name }}:${{ inputs.image_tag }}"
# Trivy's default vulnerability-DB source (mirror.gcr.io/aquasec/trivy-db) is a
# Google-hosted cache of the upstream GHCR image, used to dodge GHCR's unauthenticated
# rate limits. That cache occasionally 404s on a specific layer digest (a known,
# recurring upstream issue: aquasecurity/trivy). Point at the canonical GHCR source
# instead, and retry the DB download alone a few times — trivy exits 1 both for "DB
# download failed" and for "vulnerabilities found", so the download is a separate step
# from the scan to keep those two outcomes distinguishable.
export TRIVY_DB_REPOSITORY="ghcr.io/aquasecurity/trivy-db:2"
export TRIVY_JAVA_DB_REPOSITORY="ghcr.io/aquasecurity/trivy-java-db:1"
echo "=== Ensuring vulnerability DB is up to date ==="
db_attempt=1
db_max_attempts=3
until trivy image --download-db-only; do
if [ "$db_attempt" -ge "$db_max_attempts" ]; then
echo "=== Failed to download the Trivy vulnerability DB after ${db_max_attempts} attempts ==="
exit 1
fi
echo "=== DB download failed, retrying (${db_attempt}/${db_max_attempts})... ==="
db_attempt=$((db_attempt + 1))
sleep $((db_attempt * 5))
done
echo "=== Scanning ${FULL_IMAGE} (fail on fixable ${{ inputs.scan_severity }}) ==="
trivy image \
--exit-code 1 \
--severity "${{ inputs.scan_severity }}" \
--ignore-unfixed \
--skip-db-update \
--scanners vuln,secret \
"${FULL_IMAGE}"
+42
View File
@@ -0,0 +1,42 @@
name: Reusable Node Checks
on:
workflow_call:
inputs:
node_version:
type: string
default: "22"
install_command:
type: string
default: "npm ci"
typecheck_command:
type: string
default: "npm run typecheck"
test_command:
type: string
default: "npm test"
build_command:
type: string
default: "npm run build"
jobs:
check:
runs-on: docker
container:
image: node:${{ inputs.node_version }}-alpine
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: ${{ inputs.install_command }}
- name: Typecheck
run: ${{ inputs.typecheck_command }}
- name: Run tests
run: ${{ inputs.test_command }}
- name: Build
run: ${{ inputs.build_command }}
+73 -57
View File
@@ -40,75 +40,87 @@ jobs:
repo = "${{ gitea.repository }}" repo = "${{ gitea.repository }}"
current_run_number = int("${{ gitea.run_number }}") current_run_number = int("${{ gitea.run_number }}")
current_branch = "${{ gitea.ref_name }}" current_branch = "${{ gitea.ref_name }}"
# gitea.event_name is unreliable here: notifications.yml is itself called via
# workflow_call from the caller workflow, so inside this job it reports
# "workflow_call" rather than the top-level event (push/schedule/...) that
# actually started the run. The Actions API reports the real triggering event
# per run, so use that for the current run instead of trusting this context
# value. It is kept only as a last-resort fallback if the current run can't be
# found in the scanned history at all.
current_event = "${{ gitea.event_name }}" current_event = "${{ gitea.event_name }}"
token = os.environ["GITEA_TOKEN"] token = os.environ["API_GITEA_TOKEN"]
url = (
f"{server}/api/v1/repos/{repo}/actions/runs"
f"?page=1&limit=5"
)
print(f"Fetching workflow runs from: {url}")
print(f"Current run number: {current_run_number}") print(f"Current run number: {current_run_number}")
print(f"Current branch: {current_branch}") print(f"Current branch: {current_branch}")
print(f"Current event: {current_event}") print(f"Current event (context, may be inaccurate): {current_event}")
req = urllib.request.Request(
url,
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
},
)
with urllib.request.urlopen(req) as response:
data = json.loads(response.read().decode("utf-8"))
runs = data.get("workflow_runs", [])
print(f"Received {len(runs)} workflow runs")
previous = "unknown" previous = "unknown"
for run in runs: # Scheduled runs can push the prior run for this branch/event beyond the
run_number = run.get("run_number") # first page. Scan a bounded history so a succeeding push after a failure
status = run.get("status") # is still reported as healed.
conclusion = run.get("conclusion") for page in range(1, 11):
branch = run.get("head_branch") url = f"{server}/api/v1/repos/{repo}/actions/runs?page={page}&limit=100"
event = run.get("event") print(f"Fetching workflow runs page {page}: {url}")
req = urllib.request.Request(
print( url,
f"Inspecting run #{run_number}: " headers={
f"status={status}, " "Authorization": f"token {token}",
f"conclusion={conclusion}, " "Accept": "application/json",
f"branch={branch}, " },
f"event={event}"
) )
with urllib.request.urlopen(req) as response:
data = json.loads(response.read().decode("utf-8"))
# aktuellen Run überspringen runs = data.get("workflow_runs", [])
if int(run_number) == current_run_number: if not isinstance(runs, list):
print(" -> skipping current run") print("Received an invalid workflow-runs payload; stopping lookup.")
continue break
print(f"Received {len(runs)} workflow runs on page {page}")
if not runs:
break
# nur abgeschlossene Runs for run in runs:
if status != "completed": run_number = run.get("run_number")
print(" -> skipping non-completed run") status = run.get("status")
continue conclusion = run.get("conclusion")
branch = run.get("head_branch")
event = run.get("event")
# nur gleicher Branch print(
if branch != current_branch: f"Inspecting run #{run_number}: "
print(" -> skipping different branch") f"status={status}, "
continue f"conclusion={conclusion}, "
f"branch={branch}, "
f"event={event}"
)
# nur gleiches Event # aktuellen Run überspringen, aber dessen echtes Trigger-Event merken
if event != current_event: if str(run_number) == str(current_run_number):
print(" -> skipping different event") current_event = event or current_event
continue print(f" -> skipping current run (actual event={event})")
continue
previous = conclusion or "unknown" # nur abgeschlossene Runs
if status != "completed":
print(" -> skipping non-completed run")
continue
print(f" -> selected previous conclusion: {previous}") # nur gleicher Branch
break if branch != current_branch:
print(" -> skipping different branch")
continue
# nur gleiches Event
if event != current_event:
print(" -> skipping different event")
continue
previous = conclusion or "unknown"
print(f" -> selected previous conclusion: {previous}")
break
if previous != "unknown" or len(runs) < 100:
break
print(f"Previous conclusion final: {previous}") print(f"Previous conclusion final: {previous}")
@@ -147,7 +159,11 @@ jobs:
exit 0 exit 0
fi fi
COMMIT_SUBJECT="$(git log -1 --pretty=%s 2>/dev/null || echo 'Commit info unavailable')" COMMIT_SUBJECT="$(curl -fsS \
-H "Authorization: Bearer ${{ secrets.API_GITEA_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/git/commits/${{ gitea.sha }}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('RepoCommit',{}).get('message','').split('\n')[0])" \
2>/dev/null || echo 'Commit info unavailable')"
RUN_URL="${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }}" RUN_URL="${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }}"
cat <<EOF >/tmp/ntfy-payload.json cat <<EOF >/tmp/ntfy-payload.json
+100 -10
View File
@@ -6,15 +6,32 @@ on:
python_version: python_version:
type: string type: string
default: "3.14" default: "3.14"
source_path:
type: string
default: "app"
tests_path:
type: string
default: "tests"
test_command: test_command:
type: string type: string
default: "coverage run -m pytest" default: "coverage run -m pytest"
coverage_fail_under:
type: string
default: "80"
run_security_scan:
type: boolean
default: true
use_private_index:
# Gitea 1.27.x does not reliably propagate boolean inputs through
# reusable workflows. Keep this a string until that regression is fixed.
type: string
default: "false"
jobs: jobs:
check: check:
runs-on: docker runs-on: docker
container: container:
image: gitea.tech-buddy.at/bitbuddydev/gitea_runner_python314:dev-bda315b82bb23d83065b77d91fedf0e20d9accf1 image: gitea.tech-buddy.at/bitbuddydev/gitea_runner_python314:1.1.0
credentials: credentials:
username: ${{ secrets.REGISTRY_USERNAME }} username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -23,33 +40,106 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# Keep this step unconditional. A Gitea Actions update can otherwise silently
# skip it when a boolean workflow_call input is not propagated as expected;
# pip then misleadingly reports that the private package does not exist.
- name: Configure and verify private package index
env:
PRIVATE_INDEX_ENABLED: ${{ inputs.use_private_index }}
PRIVATE_INDEX_URL: ${{ secrets.PIP_EXTRA_INDEX_URL }}
run: |
echo "Private package index requested: ${PRIVATE_INDEX_ENABLED:-<empty>}"
if [ "${PRIVATE_INDEX_ENABLED}" != "true" ]; then
echo "Private package index is not requested for this workflow call."
exit 0
fi
if [ -z "$PRIVATE_INDEX_URL" ]; then
echo "use_private_index=true but the PIP_EXTRA_INDEX_URL secret is empty or not set"
exit 1
fi
# Probe the configured simple index before pip resolves dependencies. The
# probe deliberately prints only scheme/host/path, never URL credentials.
# NOTE: Gitea's PyPI package registry does not serve a browsable listing at
# the bare /simple/ root (only /simple/<package>/ resolves per PEP 503), so a
# 404 against the configured URL is expected there and is not itself a fault.
# This probe therefore only fails hard on auth rejection (401/403) or an
# unreachable/erroring host; a plain 404 is logged and deferred to pip's own
# resolution, which is the real signal for whether the package is installable.
python - "$PRIVATE_INDEX_URL" <<'PY'
import base64
import sys
from urllib.error import HTTPError, URLError
from urllib.parse import unquote, urlsplit, urlunsplit
from urllib.request import Request, urlopen
configured_url = sys.argv[1]
parsed = urlsplit(configured_url)
hostname = parsed.hostname or "<missing host>"
port = f":{parsed.port}" if parsed.port else ""
safe_url = urlunsplit((parsed.scheme, f"{hostname}{port}", parsed.path, parsed.query, ""))
request = Request(safe_url, headers={"Accept": "application/vnd.pypi.simple.v1+json"})
if parsed.username is not None:
credentials = f"{unquote(parsed.username)}:{unquote(parsed.password or '')}".encode()
request.add_header("Authorization", "Basic " + base64.b64encode(credentials).decode())
try:
with urlopen(request, timeout=15) as response: # nosec B310 -- configured CI package index
print(f"Private package-index probe: HTTP {response.status} at {safe_url}")
except HTTPError as error:
print(f"Private package-index probe: HTTP {error.code} at {safe_url}")
if error.code in {401, 403}:
print("Authentication was rejected by the private package index.")
raise SystemExit(1)
if error.code == 404:
print(
"Bare index root returned 404; Gitea's PyPI registry does not "
"serve a listing at /simple/ (only /simple/<package>/ resolves "
"per PEP 503). Treating this as expected and deferring to pip's "
"own dependency resolution to confirm the index actually works."
)
else:
raise SystemExit(1)
except URLError as error:
print(f"Private package-index probe could not reach {safe_url}: {error.reason}")
raise SystemExit(1)
PY
pip config --site set global.extra-index-url "$PRIVATE_INDEX_URL"
- name: Install Tools & Deps - name: Install Tools & Deps
run: | run: |
python -m pip install --upgrade pip setuptools wheel python -m pip install --upgrade pip setuptools wheel
pip install -e .[dev] || pip install -e .[test] || pip install -e . pip install -e ".[dev]" || pip install -e ".[test]" || pip install -e .
pip install ruff coverage pip-audit bandit pip install ruff coverage pip-audit bandit
- name: Linting - name: Linting
run: ruff check app tests run: ruff check ${{ inputs.source_path }} ${{ inputs.tests_path }}
- name: Tests - name: Tests
run: | run: |
${{ inputs.test_command }} ${{ inputs.test_command }}
coverage report --fail-under=60 coverage report --fail-under=${{ inputs.coverage_fail_under }}
coverage xml coverage xml
coverage html coverage html
- name: Security Scan - name: Security Scan
if: ${{ inputs.run_security_scan }}
run: | run: |
pip freeze | grep -v "git+" > req.txt pip freeze | grep -v "git+" > req.txt
pip-audit -r req.txt pip-audit -r req.txt
bandit -r app/ bandit -r ${{ inputs.source_path }}
- name: Upload Coverage - name: Upload Coverage HTML
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: coverage-report name: coverage-html
path: | path: htmlcov/
htmlcov/ if-no-files-found: warn
coverage.xml
- name: Upload Coverage XML
if: always()
uses: actions/upload-artifact@v3
with:
name: coverage-xml
path: coverage.xml
if-no-files-found: warn
@@ -0,0 +1,95 @@
name: Reusable Python Package Publish
# Builds a Python package with `python -m build` and uploads it to the Gitea PyPI registry with
# twine. Intended to run only on version tags (v*), alongside python-checks, so a package is
# never published without its tests passing.
#
# The caller gates this job on python-checks and on the tag ref, the same way docker-publish is
# gated. The job itself additionally refuses to publish when the git tag does not match the
# version declared in pyproject.toml, so a mistyped tag cannot ship the wrong version.
on:
workflow_call:
inputs:
package_name:
# informational, used only in log output
required: true
type: string
use_private_index:
# build backends that need the in-house BB* packages to resolve build dependencies
# Gitea 1.27.x does not reliably propagate boolean inputs through
# reusable workflows. Callers pass the literal string "true".
type: string
default: "false"
secrets:
REGISTRY_USERNAME: { required: true }
REGISTRY_PASSWORD: { required: true }
PYPI_UPLOAD_URL: { required: true }
PYPI_UPLOAD_USER: { required: true }
PYPI_UPLOAD_PASSWORD: { required: true }
jobs:
publish:
runs-on: docker
container:
image: gitea.tech-buddy.at/bitbuddydev/gitea_runner_python314:1.1.0
credentials:
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure private package index
if: ${{ inputs.use_private_index }}
env:
PRIVATE_INDEX_URL: ${{ secrets.PIP_EXTRA_INDEX_URL }}
run: |
if [ -z "$PRIVATE_INDEX_URL" ]; then
echo "use_private_index=true but the PIP_EXTRA_INDEX_URL secret is empty or not set"
exit 1
fi
pip config --site set global.extra-index-url "$PRIVATE_INDEX_URL"
- name: Verify the tag matches the package version
shell: bash
run: |
set -euo pipefail
if ! echo "${{ gitea.ref }}" | grep -q '^refs/tags/v'; then
echo "This workflow only publishes on version tags (refs/tags/v*), got '${{ gitea.ref }}'"
exit 1
fi
TAG_VERSION="${{ gitea.ref_name }}"
TAG_VERSION="${TAG_VERSION#v}"
PROJECT_VERSION="$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
echo "tag version : ${TAG_VERSION}"
echo "pyproject version: ${PROJECT_VERSION}"
if [ "${TAG_VERSION}" != "${PROJECT_VERSION}" ]; then
echo "Tag v${TAG_VERSION} does not match the version ${PROJECT_VERSION} in pyproject.toml"
echo "Bump the version in pyproject.toml and retag, so the published artifact matches the tag."
exit 1
fi
- name: Build the package
shell: bash
run: |
set -euo pipefail
python -m pip install --upgrade pip build twine
python -m build
echo "=== built artifacts for ${{ inputs.package_name }} ==="
ls -1 dist
- name: Check the artifacts
run: python -m twine check dist/*
- name: Upload to the package registry
env:
TWINE_USERNAME: ${{ secrets.PYPI_UPLOAD_USER }}
TWINE_PASSWORD: ${{ secrets.PYPI_UPLOAD_PASSWORD }}
run: |
python -m twine upload --repository-url "${{ secrets.PYPI_UPLOAD_URL }}" dist/*
@@ -0,0 +1,38 @@
name: Reusable Python Security Checks
on:
workflow_call:
inputs:
install_command:
type: string
default: 'python -m pip install "bandit[toml]"'
security_command:
type: string
default: "python -m bandit -r app -c pyproject.toml"
working_directory:
type: string
default: "."
secrets:
REGISTRY_USERNAME: { required: true }
REGISTRY_PASSWORD: { required: true }
jobs:
security:
runs-on: docker
container:
image: gitea.tech-buddy.at/bitbuddydev/gitea_runner_python314:1.1.0
credentials:
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install security tools
working-directory: ${{ inputs.working_directory }}
run: ${{ inputs.install_command }}
- name: Run security scan
working-directory: ${{ inputs.working_directory }}
run: ${{ inputs.security_command }}