From bfd0428a78298331dae98c3703e5b0106c99e280 Mon Sep 17 00:00:00 2001 From: moilanik Date: Sat, 20 Jun 2026 13:06:02 +0300 Subject: [PATCH 1/9] bash -> sh --- scripts/ci-report.sh | 105 +++++++++++++++++++++-------------- scripts/publish-git-pages.sh | 60 +++++++++++++------- scripts/report-status.sh | 12 ++-- 3 files changed, 109 insertions(+), 68 deletions(-) diff --git a/scripts/ci-report.sh b/scripts/ci-report.sh index e509ba6..4299283 100644 --- a/scripts/ci-report.sh +++ b/scripts/ci-report.sh @@ -1,5 +1,5 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env sh +set -eu DESCRIPTION="${1:-}" CONTEXT="${2:-}" @@ -14,53 +14,71 @@ REPORT_DIR="reports/${SUITE}" if [ ! -d "$REPORT_DIR" ]; then echo "ERROR: $REPORT_DIR not found" >&2 - bash .ci/scripts/report-status.sh failure "$DESCRIPTION" "$CONTEXT" + sh .ci/scripts/report-status.sh failure "$DESCRIPTION" "$CONTEXT" exit 1 fi -FILES=() -while IFS= read -r -d '' f; do - FILES+=("$(basename "$f")") -done < <(find "$REPORT_DIR" -maxdepth 1 -type f ! -name index.html -print0 2>/dev/null || true) +FILE_COUNT=0 +SUBDIR_COUNT=0 +ENTRIES="" -SUBDIRS=() -while IFS= read -r -d '' d; do - name="${d#$REPORT_DIR/}" - [ -f "$d/index.html" ] && SUBDIRS+=("$name") -done < <(find "$REPORT_DIR" -maxdepth 1 -type d ! -name . -print0 2>/dev/null || true) +for f in "$REPORT_DIR"/*; do + [ -f "$f" ] || continue + base=$(basename "$f") + [ "$base" = "index.html" ] && continue + FILE_COUNT=$((FILE_COUNT + 1)) + ENTRIES="${ENTRIES}file:${base} +" +done -TOTAL=$(( ${#FILES[@]} + ${#SUBDIRS[@]} )) +for d in "$REPORT_DIR"/*/; do + [ -d "$d" ] || continue + base=$(basename "$d") + [ -f "$d/index.html" ] || continue + SUBDIR_COUNT=$((SUBDIR_COUNT + 1)) + ENTRIES="${ENTRIES}dir:${base} +" +done + +TOTAL=$((FILE_COUNT + SUBDIR_COUNT)) if [ "$TOTAL" -eq 0 ]; then echo "ERROR: no reportable items in $REPORT_DIR" >&2 - bash .ci/scripts/report-status.sh failure "$DESCRIPTION" "$CONTEXT" + sh .ci/scripts/report-status.sh failure "$DESCRIPTION" "$CONTEXT" exit 1 fi -SHA8="${GITHUB_SHA:0:8}" +SHA8=$(echo "${GITHUB_SHA:-xxxxxxxx}" | cut -c1-8) humanize() { - local name="$1" - name="${name%.*}" - name="${name//-/ }" - name="${name//_/ }" - echo "${name^}" + name="$1" + name=$(echo "$name" | sed -e 's/\.[^.]*$//' -e 's/[-_]/ /g') + first=$(echo "$name" | cut -c1 | tr '[:lower:]' '[:upper:]') + rest=$(echo "$name" | cut -c2-) + echo "${first}${rest}" } generate_index() { - local html - html='' - html+="$DESCRIPTION" - html+='' - html+="

$DESCRIPTION

' - printf '%s' "$html" > "$REPORT_DIR/index.html" + { + echo '' + echo "$DESCRIPTION" + echo '' + echo "

$DESCRIPTION

' + } > "$REPORT_DIR/index.html" } STAGED="reports/${SHA8}/${SUITE}" @@ -68,20 +86,25 @@ mkdir -p "$STAGED" if [ "$TOTAL" -eq 1 ]; then cp -a "$REPORT_DIR/." "$STAGED/" - bash .ci/scripts/publish-git-pages.sh "$SUITE" + sh .ci/scripts/publish-git-pages.sh "$SUITE" - if [ ${#FILES[@]} -eq 1 ]; then - ENTRY="${FILES[0]}" + first_entry=$(echo "$ENTRIES" | head -1) + first_type=$(echo "$first_entry" | cut -d: -f1) + first_name=$(echo "$first_entry" | cut -d: -f2-) + + if [ "$first_type" = "file" ]; then + SINGLE_ENTRY="$first_name" else - ENTRY="${SUBDIRS[0]}/index.html" + SINGLE_ENTRY="${first_name}/index.html" fi - URL="${GIT_PAGES_URL}/${GITHUB_REPOSITORY}/reports/${SHA8}/${SUITE}/${ENTRY}" - bash .ci/scripts/report-status.sh "$STATUS" "$DESCRIPTION" "$CONTEXT" "" "$URL" + + URL="${GIT_PAGES_URL}/${GITHUB_REPOSITORY}/reports/${SHA8}/${SUITE}/${SINGLE_ENTRY}" + sh .ci/scripts/report-status.sh "$STATUS" "$DESCRIPTION" "$CONTEXT" "" "$URL" else generate_index cp -a "$REPORT_DIR/." "$STAGED/" - bash .ci/scripts/publish-git-pages.sh "$SUITE" - bash .ci/scripts/report-status.sh "$STATUS" "$DESCRIPTION" "$CONTEXT" "$SUITE" + sh .ci/scripts/publish-git-pages.sh "$SUITE" + sh .ci/scripts/report-status.sh "$STATUS" "$DESCRIPTION" "$CONTEXT" "$SUITE" fi rm -rf "$STAGED" diff --git a/scripts/publish-git-pages.sh b/scripts/publish-git-pages.sh index acee12c..4575bc1 100755 --- a/scripts/publish-git-pages.sh +++ b/scripts/publish-git-pages.sh @@ -1,5 +1,5 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env sh +set -eu SUITE_PATH="${1:-}" @@ -12,7 +12,7 @@ SUITE_PATH="${1:-}" OWNER="${GITHUB_REPOSITORY%%/*}" REPO="${GITHUB_REPOSITORY##*/}" -SHA8="${GITHUB_SHA:0:8}" +SHA8=$(echo "$GITHUB_SHA" | cut -c1-8) PAGES_USER="${GIT_PAGES_PUBLISH_USER:-publish}" REPORT_DIR="reports/${SHA8}/${SUITE_PATH%/}" REPORT_BASE="${GIT_PAGES_URL}/${OWNER}/${REPO}/reports/${SHA8}" @@ -33,17 +33,30 @@ else fi mkdir -p "$TARGET" cp -a "$REPORT_DIR/." "$TARGET/" -if [ ! -f "$TARGET/index.html" ]; then - items=() - while IFS= read -r -d '' f; do - items+=("$(basename "$f")") - done < <(find "$TARGET" -maxdepth 1 -type f ! -name index.html -print0 2>/dev/null || true) - while IFS= read -r -d '' d; do - name=$(basename "$d") - [ -f "$d/index.html" ] && items+=("$name") - done < <(find "$TARGET" -maxdepth 1 -type d ! -name . -print0 2>/dev/null || true) - if [ ${#items[@]} -gt 1 ]; then +if [ ! -f "$TARGET/index.html" ]; then + ITEM_LIST="" + ITEM_COUNT=0 + + for f in "$TARGET"/*; do + [ -f "$f" ] || continue + base=$(basename "$f") + [ "$base" = "index.html" ] && continue + ITEM_LIST="${ITEM_LIST}file:${base} +" + ITEM_COUNT=$((ITEM_COUNT + 1)) + done + + for d in "$TARGET"/*/; do + [ -d "$d" ] || continue + base=$(basename "$d") + [ -f "$d/index.html" ] || continue + ITEM_LIST="${ITEM_LIST}dir:${base} +" + ITEM_COUNT=$((ITEM_COUNT + 1)) + done + + if [ "$ITEM_COUNT" -gt 1 ]; then { echo '' echo "Test report ${SHA8}" @@ -53,16 +66,21 @@ if [ ! -f "$TARGET/index.html" ]; then echo 'a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}' echo '' echo "

Test report ${SHA8}

' } > "$TARGET/index.html" fi @@ -74,7 +92,7 @@ EOF find "$WORK/$OWNER" \( -type f -o -type l \) -print | sed "s|^${WORK}/||" | tar -cf "$TAR" -C "$WORK" -T - publish() { - local method="$1" + method="$1" curl -sS -X "$method" "$PUBLISH_SITE_URL" \ -u "${PAGES_USER}:${GIT_PAGES_PUBLISH_TOKEN}" \ -H "Content-Type: application/x-tar" \ diff --git a/scripts/report-status.sh b/scripts/report-status.sh index 5d7f8c9..6c319ac 100755 --- a/scripts/report-status.sh +++ b/scripts/report-status.sh @@ -1,11 +1,10 @@ -#!/usr/bin/env bash -set -euo pipefail - -# https://docs.gitea.com/api/next/#tag/repository/operation/repoCreateStatus +#!/usr/bin/env sh +set -eu STATE="${1:-}" DESCRIPTION="${2:-}" -KEY="${3:-commit-${GITHUB_SHA:0:8}}" +SHA8=$(echo "${GITHUB_SHA:-}" | cut -c1-8) +KEY="${3:-commit-${SHA8}}" SUITE="${4:-}" CUSTOM_URL="${5:-}" @@ -18,7 +17,8 @@ if [ -n "$CUSTOM_URL" ]; then URL="$CUSTOM_URL" elif [ -n "$SUITE" ]; then SUITE="${SUITE%/}/" - URL="${GIT_PAGES_URL}/${GITHUB_REPOSITORY}/reports/${GITHUB_SHA:0:8}/${SUITE}" + SHA8_CUT=$(echo "$GITHUB_SHA" | cut -c1-8) + URL="${GIT_PAGES_URL}/${GITHUB_REPOSITORY}/reports/${SHA8_CUT}/${SUITE}" else URL="${GITEA_API_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" fi -- 2.52.0 From 0a9a9c88f14db5d5584d3cd47299a5c62a675f30 Mon Sep 17 00:00:00 2001 From: moilanik Date: Sat, 20 Jun 2026 13:06:15 +0300 Subject: [PATCH 2/9] =?UTF-8?q?konttipolitiikka=20p=C3=A4ivitys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/ci-container-build/SKILL.md | 54 ++++++++++++++-- skills/consumer-pipelines/REFERENCE.md | 87 ++++++++++++++++++++++++++ skills/consumer-pipelines/SKILL.md | 49 ++++++++++++--- 3 files changed, 174 insertions(+), 16 deletions(-) diff --git a/skills/ci-container-build/SKILL.md b/skills/ci-container-build/SKILL.md index 4e9c659..e19f598 100644 --- a/skills/ci-container-build/SKILL.md +++ b/skills/ci-container-build/SKILL.md @@ -32,6 +32,31 @@ Kun kontti on pushattu registryyn, se on muiden pipeline-jobien käytettävissä `latest`-tägillä — rebuild = käyttöönotto. Mitään versioviittauksia ei tarvitse päivittää. +## Offline-periaate (DoD) + +CI-kontin **Definition of Done**: + +> Kontti ei lataa mitään pipeline-vaiheessa (`workflow run` -stepit) eikä kontin +> runtime-prosessissa (`container:` / `docker run`). Kaikki riippuvuudet +> (kielikohtaiset paketit, työkalut, binäärit) on joko: +> - Pre-cachattu kontin **build-vaiheessa** Dockerfilessä, TAI +> - Kopioitu multi-stage buildilla toisesta imagesta (`COPY --from`) +> +> Ainoa sallittu lataushetki on `docker build`. Sen jälkeen kontti toimii +> ilman verkkoyhteyttä. + +**Miksi:** Toistettavuus, air gap -yhteensopivuus, nopeus. Pipeline ei saa +epäonnistua sen takia että ulkoinen registry on alhaalla tai että `go mod download` +joutuu latamaan 100 modulia jokaisella testiajolla. + +**Kielikohtaiset pre-cachet:** Jos kontissa ajetaan kielikohtaista testiä +(Go, Java, Node, Python, ...), kaikki kielikohtaiset riippuvuudet on +pre-cachattava Dockerfilessä build-vaiheessa: +- Go: `COPY go.mod go.sum ./` → `RUN go mod download` +- Java/Maven: `COPY pom.xml ./` → `RUN mvn dependency:go-offline` +- Node: `COPY package.json package-lock.json ./` → `RUN npm ci --omit=dev` +- Python: `COPY requirements.txt ./` → `RUN pip wheel --wheel-dir=/wheels -r requirements.txt` → `COPY --from` käyttöön + ## Nimeäminen CI-kontin build-workflow noudattaa samaa nimeämiskonventiota kuin muutkin @@ -123,27 +148,44 @@ tag: latest ### Dockerfile -Dockerfile yhdistää tarvitut työkalut yhteen konttiin. Molemmat tavat kelpaavat: +Dockerfile yhdistää tarvitut työkalut yhteen konttiin. +**Kaikki riippuvuudet ladataan build-vaiheessa — kontti on täysin itseriittoinen.** ```dockerfile # Tapa A: COPY --from toisesta imagesta FROM __BASE_IMAGE__:__VERSION__ COPY --from=__SOURCE_IMAGE__:__VERSION__ /path/to/binary /usr/local/bin/ -RUN apk add --no-cache __PAKETIT__ # Ei koskaan git:iä — kloonaus kuuluu pipelinelle +RUN apk add --no-cache __PAKETIT__ -# Tapa B: curl-lataus (normaali Dockerfilessa) +# Tapa B: Build-vaiheen curl-lataus FROM __BASE_IMAGE__:__VERSION__ RUN apk add --no-cache curl __PAKETIT__ && \ curl -fsSL __URL__/__BINARY__.tar.gz | tar xz -C /usr/local/bin && \ apk del curl + +# Tapa C: Multi-stage + kielikohtainen pre-cache +FROM __BASE_IMAGE__:__VERSION__ AS deps +COPY go.mod go.sum ./ +RUN go mod download + +FROM deps AS build +COPY . . +RUN go test -c -o /tmp/test.bin ./... + +FROM __BASE_IMAGE__:__VERSION__ +COPY --from=deps /go/pkg/mod /go/pkg/mod +COPY --from=build /tmp/test.bin /usr/local/bin/test ``` -`COPY --from` on kevyempi (ei curl-asennusta). `curl` on selkeämpi kun binääri -tulee suoraan GitHub Releasesista tai vastaavasta. +`COPY --from` on kevyempi (ei curl-asennusta). `curl` (Tapa B) on sallittu +vain build-vaiheessa — `apk del curl` poistaa työkalun ennen runtimea. +Tapa C pre-cacheaa kielikohtaiset riippuvuudet ja tuottaa täysin +offline-runtime-kontin. ## Mitä EI kannata tehdä - Älä lisää `workflow_call`-triggariä — CI-konttia ei koskaan buildata automaattisesti - Älä poista `.`-prefiksiä olemassaolevista tiedostoista — ne kuuluvat monorepo-nimeämiskonventioon - Älä sisällytä CI-konttiin mitään sovelluskoodia — vain työkalut -- Älä koskaan asenna `git`:iä CI-konttiin — repon kloonaus ja checkout ovat Gitea Actionsin natiiveja operaatioita, eivät kontin vastuulla. Git paisuttaa konttia turhaan ja luo harhan että kontti hallitsee repoa +- Älä koskaan lataa mitään pipeline- tai runtime-vaiheessa — kaikki lataukset kuuluvat `docker build` -vaiheeseen (Offline-periaate) +- Älä jätä kielikohtaisia riippuvuuksia pre-cachaamatta — `go mod download`, `npm install`, `mvn dependency:go-offline` jne. ajetaan Dockerfilessä, ei pipelinessä diff --git a/skills/consumer-pipelines/REFERENCE.md b/skills/consumer-pipelines/REFERENCE.md index 64d93c1..58ae7ce 100644 --- a/skills/consumer-pipelines/REFERENCE.md +++ b/skills/consumer-pipelines/REFERENCE.md @@ -2,6 +2,93 @@ Mallipohjat, esimerkit ja konfiguraatiot. Katso säännöt `SKILL.md`:stä. +## Pre-cache-esimerkit (Offline Container) + +Alla Dockerfile-esimerkit kielikohtaisista pre-cacheista. Kaikki ajetaan +build-vaiheessa — kontti on täysin itseriittoinen eikä lataa mitään +pipeline- tai runtime-vaiheessa. + +### Go + +```dockerfile +FROM golang:1.24-alpine AS deps +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + +FROM deps AS test-build +COPY . . +RUN go test -c -o /tmp/test.bin ./... + +FROM alpine:3.21 +RUN apk add --no-cache git nodejs +COPY --from=deps /go/pkg/mod /go/pkg/mod +COPY --from=test-build /tmp/test.bin /usr/local/bin/test +``` + +### Node.js + +```dockerfile +FROM node:22-alpine AS deps +WORKDIR /build +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +FROM node:22-alpine +RUN apk add --no-cache git +COPY --from=deps /build/node_modules /app/node_modules +COPY . /app +WORKDIR /app +``` + +### Java / Maven + +```dockerfile +FROM maven:3.9-eclipse-temurin-21 AS deps +WORKDIR /build +COPY pom.xml ./ +RUN mvn dependency:go-offline -B + +FROM maven:3.9-eclipse-temurin-21 AS build +COPY --from=deps /root/.m2 /root/.m2 +COPY . . +RUN mvn package -B -DskipTests + +FROM eclipse-temurin:21-jre +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* +COPY --from=build /build/target/*.jar /app/app.jar +WORKDIR /app +``` + +### Python + +```dockerfile +FROM python:3.12-alpine AS deps +WORKDIR /build +COPY requirements.txt ./ +RUN pip wheel --wheel-dir=/wheels -r requirements.txt + +FROM python:3.12-alpine +RUN apk add --no-cache git +COPY --from=deps /build/wheels /wheels +COPY --from=deps /build/requirements.txt / +RUN pip install --no-index --find-links=/wheels -r /requirements.txt && rm -rf /wheels +COPY . /app +WORKDIR /app +``` + +### Helm + Node.js (korvaa helm-build-push.yml:n runtime-apk) + +```dockerfile +FROM alpine/helm:3.16.0 AS helm-bin +FROM node:22-alpine +RUN apk add --no-cache git +COPY --from=helm-bin /usr/bin/helm /usr/local/bin/helm +``` + +Tämä kontti korvaa `helm-build-push.yml`:n `alpine/helm:3.19.0`-image-riippuvuuden +ja poistaa tarpeen asentaa node.js runtime-vaiheessa. + ## Reititin — täydellinen esimerkki ```yaml diff --git a/skills/consumer-pipelines/SKILL.md b/skills/consumer-pipelines/SKILL.md index 31a042a..542fc0c 100644 --- a/skills/consumer-pipelines/SKILL.md +++ b/skills/consumer-pipelines/SKILL.md @@ -82,7 +82,36 @@ koko stepin ensimmäisellä failaavalla komennolla, ja loput jäävät ajamatta. CI-kontin build-workflow'n template: `skills/ci-container-build/SKILL.md`. -### 4.1 CI-kontin ajaminen jobissa +### 4.1 Offline Container -vaatimus (DoD) + +CI-kontin (ja kaikkien pipeline-konttien) on oltava täysin itseriittoisia: + +> Kontti ei lataa mitään pipeline-vaiheessa (`workflow run` -stepit) eikä +> kontin runtime-prosessissa (`container:` / `docker run`). Kaikki +> riippuvuudet pre-cachataan `docker build` -vaiheessa. +> Ainoa sallittu lataushetki on `docker build`. + +**Esimerkkejä rikkomuksista:** +- `apk add`, `apt-get install`, `npm install`, `go mod download`, `pip install` + pipeline-stepissä +- `curl | tar xz` runtime-vaiheessa +- Node.js-konttikuva ilman nodea (joudutaan asentamaan lennossa) + +### 4.2 Kielikohtainen pre-cache + +Kun kontissa testataan kielikohtaista koodia, kaikki riippuvuudet on +pre-cachattava Dockerfilessä, ei pipeline-stepissä: + +| Kieli | Pre-cache Dockerfilessä | +|---|---| +| Go | `COPY go.mod go.sum ./` → `RUN go mod download` | +| Java/Maven | `COPY pom.xml ./` → `RUN mvn dependency:go-offline` | +| Node | `COPY package.json package-lock.json ./` → `RUN npm ci --omit=dev` | +| Python | `COPY requirements.txt ./` → `RUN pip install -r requirements.txt` | + +Katso tarkat Dockerfile-esimerkit `REFERENCE.md`:stä. + +### 4.3 CI-kontin ajaminen jobissa Ainoa sallittu tapa on `container:`-direktiivi. `docker run` komennolla kontin käynnistäminen stepin sisällä on anti-pattern. @@ -91,7 +120,8 @@ Katso CI-kontin template `REFERENCE.md`:stä. **Huomio `actions/checkout@v4`:stä:** `container:`-direktiivillä kaikki stepit ajetaan kontin *sisällä* — myös `actions/checkout@v4`. Se on JavaScript-action -joka vaatii sekä `nodejs` että `git`. Varmista että CI-kontin Dockerfilessä on molemmat. +joka vaatii sekä `nodejs` että `git`. Varmista että CI-kontin Dockerfilessä on +molemmat — muuten checkout ei toimi ja pipeline failaa. ## 5. Raporttitasot @@ -243,15 +273,14 @@ helm-build-push: # chart_path: '.' # oletus, vaihda jos Chart.yaml on alihakemistossa ``` -**Node.js-kompromissi:** `actions/checkout@v4` on JavaScript-action. -Kontissa `alpine/helm` ei ole node.js:ää, joten se asennetaan lennossa -`apk add --no-cache nodejs` ennen checkouttia. +**Vanhentunut käytäntö:** Nykyinen `helm-build-push.yml` asentaa node.js:n +lennossa `apk add --no-cache nodejs` ennen checkouttia — tämä rikkoo +Offline Container -vaatimusta (4.1). -- Vaatii internet-yhteyden -- Ei toimi air gap -ympäristössä -- Korvaa tarvittaessa custom-kontilla (helm + nodejs): - rakenna `ci-container-build`-skillillä ja päivitä workflow'n - `container: image:` osoittamaan omaan konttiin +**Korjaustoimenpide:** Rakenna custom CI-kontti `ci-container-build`-skillillä +jossa on helm + nodejs + git (katso pre-cache-esimerkit `REFERENCE.md`:stä), +päivitä workflow'n `container: image:` osoittamaan omaan konttiin, ja poista +runtime-apk. **Yksittäisten Helm-UI-linkkien raportointi:** `HELM_UI_URL` on tarkoitettu yleiselle registry UI:lle — provider muodostaa linkin -- 2.52.0 From a039e6637e5b035409dbaa9c01d9abc0413271ea Mon Sep 17 00:00:00 2001 From: moilanik Date: Sat, 20 Jun 2026 13:34:03 +0300 Subject: [PATCH 3/9] fix fragile tests --- tests/check-version.bats | 1 - tests/dispatch-workflow.bats | 14 +++++++------- tests/helpers/mock-api.sh | 22 +++++++++++++++++++--- tests/publish-git-pages.bats | 7 +++++-- tests/report-status.bats | 6 ++++-- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/tests/check-version.bats b/tests/check-version.bats index f27f4cd..b34472d 100644 --- a/tests/check-version.bats +++ b/tests/check-version.bats @@ -5,7 +5,6 @@ source "$BATS_TEST_DIRNAME/helpers/mock-api.sh" setup() { export GITEA_TOKEN=test-token export GIT_TAG_PREFIX="" - export SERVER_URL="http://localhost:18080" export REPO="niko/test" export SHA="abc123" rm -rf /tmp/build-ctx diff --git a/tests/dispatch-workflow.bats b/tests/dispatch-workflow.bats index 3f271de..dec53f5 100644 --- a/tests/dispatch-workflow.bats +++ b/tests/dispatch-workflow.bats @@ -19,7 +19,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"completed","conclusion":"success"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" [ "$status" -eq 0 ] } @@ -31,7 +31,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"completed","conclusion":"failure"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" [ "$status" -eq 1 ] } @@ -43,7 +43,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"completed","conclusion":"cancelled"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" [ "$status" -eq 1 ] } @@ -61,7 +61,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"running"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" "0.001" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" "0.001" [ "$status" -eq 124 ] } @@ -70,7 +70,7 @@ teardown() { {"code":500} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" [ "$status" -eq 1 ] } @@ -81,7 +81,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"completed","conclusion":"success"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" [ "$status" -eq 0 ] path=$(mock_get_first_request_path) [[ "$path" == *"/api/v1/repos/test-owner/test-repo/actions/workflows/test.yml/dispatches"* ]] @@ -126,7 +126,7 @@ teardown() { {"code":200,"body":{"workflow_runs":[]}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{}' "http://localhost:18080" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{}' "$GITEA_API_URL" "test-token-abc123" [ "$status" -eq 1 ] [[ "$output" == *"ERROR"* ]] } diff --git a/tests/helpers/mock-api.sh b/tests/helpers/mock-api.sh index ae7a55f..b5420fb 100644 --- a/tests/helpers/mock-api.sh +++ b/tests/helpers/mock-api.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -MOCK_PORT=18080 +MOCK_PORT="" MOCK_PID="" MOCK_REQUEST_FILE="" MOCK_RESPONSE_CODE=201 @@ -9,11 +9,17 @@ MOCK_STATE_FILE="/tmp/mock_api_state" MOCK_SEQUENCE_FILE="" MOCK_CONFIG_FILE="" +_free_port() { + python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()" +} + _kill_port() { local pids pids=$(lsof -ti ":$MOCK_PORT" 2>/dev/null) || true - [ -n "$pids" ] && kill -9 $pids 2>/dev/null || true + [ -n "$pids" ] && kill $pids 2>/dev/null || true sleep 0.5 + pids=$(lsof -ti ":$MOCK_PORT" 2>/dev/null) || true + [ -n "$pids" ] && kill -9 $pids 2>/dev/null || true } _wait_port_free() { @@ -26,10 +32,14 @@ _wait_port_free() { _wait_port_ready() { local i=0 - while ! lsof -ti ":$MOCK_PORT" >/dev/null 2>&1 && [ $i -lt 5 ]; do + while [ $i -lt 50 ]; do + if nc -z localhost "$MOCK_PORT" 2>/dev/null; then + return 0 + fi sleep 0.2 i=$((i + 1)) done + return 1 } mock_set_sequence() { @@ -43,6 +53,12 @@ mock_clear_sequence() { } mock_start() { + MOCK_PORT=$(_free_port) + export MOCK_PORT + MOCK_URL="http://localhost:${MOCK_PORT}" + export SERVER_URL="$MOCK_URL" + export GITEA_API_URL="$MOCK_URL" + MOCK_RESPONSE_CODE="${MOCK_RESPONSE_CODE:-201}" MOCK_REQUEST_FILE=$(mktemp) echo "$MOCK_REQUEST_FILE" > "$MOCK_STATE_FILE" diff --git a/tests/publish-git-pages.bats b/tests/publish-git-pages.bats index 1e62214..9723622 100644 --- a/tests/publish-git-pages.bats +++ b/tests/publish-git-pages.bats @@ -63,9 +63,10 @@ teardown() { {"code":200,"body":"published"} ]' mock_start + export GIT_PAGES_URL="http://localhost:${MOCK_PORT}" run bash scripts/publish-git-pages.sh "unit-tests" [ "$status" -eq 0 ] - [[ "$output" == "http://localhost:18080/test-owner/test-repo/reports/abc123de" ]] + [[ "$output" == "${GIT_PAGES_URL}/test-owner/test-repo/reports/abc123de" ]] } @test "publish with suite subpath" { @@ -75,9 +76,10 @@ teardown() { {"code":200,"body":"published"} ]' mock_start + export GIT_PAGES_URL="http://localhost:${MOCK_PORT}" run bash scripts/publish-git-pages.sh "sub/suite" [ "$status" -eq 0 ] - [[ "$output" == "http://localhost:18080/test-owner/test-repo/reports/abc123de" ]] + [[ "$output" == "${GIT_PAGES_URL}/test-owner/test-repo/reports/abc123de" ]] } @test "git-pages returns HTTP 500 → exit 1" { @@ -85,6 +87,7 @@ teardown() { {"code":500,"body":"internal error"} ]' mock_start + export GIT_PAGES_URL="http://localhost:${MOCK_PORT}" run bash scripts/publish-git-pages.sh "unit-tests" [ "$status" -eq 1 ] [[ "$output" == *"500"* ]] diff --git a/tests/report-status.bats b/tests/report-status.bats index 786c731..c29a5d2 100644 --- a/tests/report-status.bats +++ b/tests/report-status.bats @@ -23,7 +23,8 @@ teardown() { body=$(mock_get_request_body) [[ "$body" == *'"state":"pending"'* ]] [[ "$body" == *'"description":"Building project"'* ]] - [[ "$body" == *'"target_url":"http://localhost:18080/test-owner/test-repo/actions/runs/42"'* ]] + expected_url="${GITEA_API_URL}/test-owner/test-repo/actions/runs/42" + [[ "$body" == *"\"target_url\":\"${expected_url}\""* ]] method=$(mock_get_request_method) [[ "$method" == "POST" ]] } @@ -44,7 +45,8 @@ teardown() { [ "$status" -eq 0 ] body=$(mock_get_request_body) [[ "$body" == *'"state":"failure"'* ]] - [[ "$body" == *'"target_url":"http://localhost:18080/test-owner/test-repo/actions/runs/42"'* ]] + expected_url="${GITEA_API_URL}/test-owner/test-repo/actions/runs/42" + [[ "$body" == *"\"target_url\":\"${expected_url}\""* ]] } @test "default key when not provided" { -- 2.52.0 From cd6ff8830cd62b56f005a70bb64faf5a39892817 Mon Sep 17 00:00:00 2001 From: moilanik Date: Sat, 20 Jun 2026 13:44:36 +0300 Subject: [PATCH 4/9] testien nopeutus --- .../step_definitions/commit-status.steps.js | 10 +++++-- .../features/step_definitions/common.steps.js | 9 ++++--- .../step_definitions/test-execution.steps.js | 24 +++++++++++------ tests/helpers/mock-api.sh | 26 ++----------------- 4 files changed, 31 insertions(+), 38 deletions(-) diff --git a/tests/features/step_definitions/commit-status.steps.js b/tests/features/step_definitions/commit-status.steps.js index 3cbe24a..4df2e3a 100644 --- a/tests/features/step_definitions/commit-status.steps.js +++ b/tests/features/step_definitions/commit-status.steps.js @@ -27,7 +27,8 @@ function bashQuiet(cmd) { } function runReportStatus(args) { - return bash(`export GITEA_API_URL="http://localhost:18080" GITEA_TOKEN="test-token-abc123" GIT_PAGES_URL="https://reports.example.com" GITHUB_REPOSITORY="test-owner/test-repo" GITHUB_SHA="abc123def456789012345678901234567890abcd" GITHUB_RUN_ID="42"; bash "${REPORT_SCRIPT}" ${args}`); + const apiUrl = process.env.GITEA_API_URL || 'http://localhost:18080'; + return bash(`export GITEA_API_URL="${apiUrl}" GITEA_TOKEN="test-token-abc123" GIT_PAGES_URL="https://reports.example.com" GITHUB_REPOSITORY="test-owner/test-repo" GITHUB_SHA="abc123def456789012345678901234567890abcd" GITHUB_RUN_ID="42"; bash "${REPORT_SCRIPT}" ${args}`); } function getMockBody() { @@ -110,7 +111,12 @@ Then('the source commit shows the deployment status alongside the build status', When('a build step tries to report status but the build system is unavailable', function () { bashQuiet(`source "${MOCK_SCRIPT}" && mock_stop`); execSync('sleep 0.3', { stdio: 'ignore' }); - bashQuiet(`source "${MOCK_SCRIPT}" && mock_set_response 500 && mock_start`); + const out = execSync(`bash -o pipefail -c 'source "${MOCK_SCRIPT}" && mock_set_response 500 && mock_start >&2 && echo "$GITEA_API_URL"'`, { + cwd: PROJECT_ROOT, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + process.env.GITEA_API_URL = out.trim(); const r = runReportStatus('success "Should fail"'); this.reportStatusFailed = (r.status !== 0); }); diff --git a/tests/features/step_definitions/common.steps.js b/tests/features/step_definitions/common.steps.js index 6c665a8..6460bd7 100644 --- a/tests/features/step_definitions/common.steps.js +++ b/tests/features/step_definitions/common.steps.js @@ -6,15 +6,16 @@ const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); const MOCK_SCRIPT = path.join(PROJECT_ROOT, 'tests', 'helpers', 'mock-api.sh'); Before({ tags: '@mock' }, function () { - const out = execSync(`bash -c 'source "${MOCK_SCRIPT}" && mock_start && sleep 1 && curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:18080/api/v1/repos/health/check'`, { + const out = execSync(`bash -o pipefail -c 'source "${MOCK_SCRIPT}" && mock_start >&2 && echo "$GITEA_API_URL"'`, { cwd: PROJECT_ROOT, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); - const trimmed = out.trim(); - if (!trimmed.startsWith('2') && !trimmed.startsWith('4')) { - throw new Error(`Mock server failed to start (HTTP ${trimmed})`); + const apiUrl = out.trim(); + if (!apiUrl.startsWith('http')) { + throw new Error(`Mock server failed to start (no API URL: ${apiUrl})`); } + process.env.GITEA_API_URL = apiUrl; }); After({ tags: '@mock' }, function () { diff --git a/tests/features/step_definitions/test-execution.steps.js b/tests/features/step_definitions/test-execution.steps.js index 8657eb7..100af60 100644 --- a/tests/features/step_definitions/test-execution.steps.js +++ b/tests/features/step_definitions/test-execution.steps.js @@ -21,9 +21,15 @@ function bash(cmd) { } } +function getFreePort() { + const out = execSync(`python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()"`, { encoding: 'utf-8' }); + return parseInt(out.trim(), 10); +} + function setupMock(seqJson) { - execSync('lsof -ti :18080 2>/dev/null | xargs -r kill -9 2>/dev/null || true', { stdio: 'ignore' }); - execSync('sleep 0.4', { stdio: 'ignore' }); + const port = getFreePort(); + process.env.MOCK_PORT = String(port); + process.env.GITEA_API_URL = `http://localhost:${port}`; const seqFile = path.join(os.tmpdir(), `cucumber_seq_${Date.now()}.json`); fs.writeFileSync(seqFile, seqJson); @@ -34,17 +40,16 @@ function setupMock(seqJson) { const configFile = path.join(os.tmpdir(), `cucumber_cfg_${Date.now()}.txt`); fs.writeFileSync(configFile, `SEQUENCE\n${seqJson}\n${idxFile}`); - const proc = spawn('python3', [MOCK_SERVER, '18080', configFile, reqFile], { + const proc = spawn('python3', [MOCK_SERVER, String(port), configFile, reqFile], { cwd: PROJECT_ROOT, detached: true, stdio: 'ignore', }); proc.unref(); - this._mockProc = proc; for (let i = 0; i < 20; i++) { try { - execSync('curl -s --max-time 1 http://localhost:18080/', { stdio: 'ignore' }); + execSync(`nc -z localhost ${port}`, { stdio: 'ignore' }); fs.writeFileSync(idxFile, '0'); return; } catch (_) {} @@ -69,7 +74,8 @@ When('a test workflow is dispatched to a test project', function () { { code: 200, body: { workflow_runs: [{ id: 1, status: 'running' }] } }, { code: 200, body: { id: 1, status: 'completed', conclusion: 'success' } }, ])); - const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123"'); + const url = process.env.GITEA_API_URL; + const r = runDispatch(`"test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "${url}" "test-token-abc123"`); this.dispatchResult = r.status; }); @@ -87,7 +93,8 @@ When('a test workflow is dispatched and the tests fail', function () { { code: 200, body: { workflow_runs: [{ id: 1, status: 'running' }] } }, { code: 200, body: { id: 1, status: 'completed', conclusion: 'failure' } }, ])); - const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123"'); + const url = process.env.GITEA_API_URL; + const r = runDispatch(`"test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "${url}" "test-token-abc123"`); this.dispatchResult = r.status; }); @@ -103,7 +110,8 @@ When('a test workflow is dispatched but does not finish within the allowed time' { code: 200, body: { id: 1, status: 'running' } }, { code: 200, body: { id: 1, status: 'running' } }, ])); - const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123" "0.001"'); + const url = process.env.GITEA_API_URL; + const r = runDispatch(`"test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "${url}" "test-token-abc123" "0.001"`); this.dispatchResult = r.status; }); diff --git a/tests/helpers/mock-api.sh b/tests/helpers/mock-api.sh index b5420fb..583b1c9 100644 --- a/tests/helpers/mock-api.sh +++ b/tests/helpers/mock-api.sh @@ -13,30 +13,13 @@ _free_port() { python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()" } -_kill_port() { - local pids - pids=$(lsof -ti ":$MOCK_PORT" 2>/dev/null) || true - [ -n "$pids" ] && kill $pids 2>/dev/null || true - sleep 0.5 - pids=$(lsof -ti ":$MOCK_PORT" 2>/dev/null) || true - [ -n "$pids" ] && kill -9 $pids 2>/dev/null || true -} - -_wait_port_free() { - local i=0 - while lsof -ti ":$MOCK_PORT" >/dev/null 2>&1 && [ $i -lt 50 ]; do - sleep 0.1 - i=$((i + 1)) - done -} - _wait_port_ready() { local i=0 - while [ $i -lt 50 ]; do + while [ $i -lt 10 ]; do if nc -z localhost "$MOCK_PORT" 2>/dev/null; then return 0 fi - sleep 0.2 + sleep 0.1 i=$((i + 1)) done return 1 @@ -73,9 +56,6 @@ mock_start() { echo "$MOCK_RESPONSE_CODE" >> "$MOCK_CONFIG_FILE" fi - _kill_port - _wait_port_free - nohup python3 "$(dirname "${BASH_SOURCE[0]}")/mock-server.py" "$MOCK_PORT" "$MOCK_CONFIG_FILE" "$MOCK_REQUEST_FILE" \ /dev/null 2>&1 & MOCK_PID=$! @@ -84,8 +64,6 @@ mock_start() { mock_stop() { [ -n "${MOCK_PID:-}" ] && kill -9 "$MOCK_PID" 2>/dev/null || true - _kill_port - _wait_port_free [ -n "${MOCK_REQUEST_FILE:-}" ] && rm -f "${MOCK_REQUEST_FILE}" 2>/dev/null || true [ -n "${MOCK_SEQUENCE_FILE:-}" ] && rm -f "${MOCK_SEQUENCE_FILE}" 2>/dev/null || true [ -n "${MOCK_SEQUENCE_FILE:-}" ] && rm -f "${MOCK_SEQUENCE_FILE}.idx" 2>/dev/null || true -- 2.52.0 From a61b22d8a73f6671c5c54ee2ddaa457b78d58ad0 Mon Sep 17 00:00:00 2001 From: moilanik Date: Sat, 20 Jun 2026 13:49:04 +0300 Subject: [PATCH 5/9] =?UTF-8?q?po=C3=A4jhpo=C3=A4j?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/helpers/mock-api.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/helpers/mock-api.sh b/tests/helpers/mock-api.sh index 583b1c9..f7db2cd 100644 --- a/tests/helpers/mock-api.sh +++ b/tests/helpers/mock-api.sh @@ -15,7 +15,7 @@ _free_port() { _wait_port_ready() { local i=0 - while [ $i -lt 10 ]; do + while [ $i -lt 30 ]; do if nc -z localhost "$MOCK_PORT" 2>/dev/null; then return 0 fi @@ -58,6 +58,7 @@ mock_start() { nohup python3 "$(dirname "${BASH_SOURCE[0]}")/mock-server.py" "$MOCK_PORT" "$MOCK_CONFIG_FILE" "$MOCK_REQUEST_FILE" \ /dev/null 2>&1 & + disown MOCK_PID=$! _wait_port_ready } -- 2.52.0 From b5faebcaae5e206effb31e5af9623f2946ce4c67 Mon Sep 17 00:00:00 2001 From: moilanik Date: Sat, 20 Jun 2026 13:52:19 +0300 Subject: [PATCH 6/9] ardfbhaedrhb --- .../__pycache__/mock-server.cpython-314.pyc | Bin 0 -> 6042 bytes tests/helpers/mock-server.py | 7 +++++++ 2 files changed, 7 insertions(+) create mode 100644 tests/helpers/__pycache__/mock-server.cpython-314.pyc diff --git a/tests/helpers/__pycache__/mock-server.cpython-314.pyc b/tests/helpers/__pycache__/mock-server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2315b15f2637e6edd85017fccdb0d3cb1262d149 GIT binary patch literal 6042 zcmeGgNo*U}_0L8QIix6&S}01gs91|B%XX~Du_HT9WLc3M+X_8ah%_ZNV91fggd*vg zQ53reGp+$qO#;ewP}5mp0nNb%dMJ9zsX&rLb1I}ziP#STY0yK_n_vZS;a>XwkV9%S z9Hh-HpXB??d;k6At)Kg=>{bLNn0os4uPg}tgLI5yE;TmR7=+Sj9EsE!q)~cJYqVZ7 z8l%^&#_BbvaUs@kGgwsWHq=+@jH6@kBNTNZ)Qm!mzp6rGwAx^;KC0MjMZmAxz*OoA zKb_+LyP~Cy?5wu3n$a4kdOSuOmG`I#kD#VEQ){+IP%&Gm>>|$l7J;I8W-pw-1^kTBzekPwILszfasu^986ut zJeei|E5!lSd*G*@0FXxOG|Sbj*=ye4mmPn1Jg2O=>KBw-vo~gMUcPnp#?^eozP!u# zfI(Hw+41c7JLCUZXDO}*JE|Y5`+&i?Sw3$C3OcKY)L0+ zOwEq^ScQkB6rv88n^j_(j2RGw53p}4b{JL3o;$q?2_x8|Q|NbI2mKC$e-c0%ty%2v zPUVn_A1nl)qJk!aPq1uV0V*hzN6UCa zf)MTW?4U?tXvgMgm7;@02zm)nKyP%QHn{B6gI8!jF^O$Oq{qKB)<`jd-Dwz+9j6Xa zWjZ#%1k2fh-?@z?blXsXHFok8y%z;*4VuTzE3H%#IGOtcy(y;!kYLiRgO>&T5Gx%x#{-qn#gU2xTw`t6IC7TtMQHyLaAsP>NM1JB~= ze4}s4uF|w9XnZpQ ze#A{}EB>-+RS*Hj;NmYEP6Z3@l0I5exbu|Aq}TvuLn0fX0dfJBz+O{|3zp5NqQs)f z*ym1%kTL~Ken_~tmjGw%28`59K9x`^njfd|grelE`72d}$%G~+v;*hl#3V#cvm&SB z;fM?Y(@*r6qSjx z>u~_{;?M=@g)`@d6k_T=vqD@vHciXgR5Gd%SC55AaU&8jBVrDijmHu)rY2OPC!M-s zlq|z0#gmg@8JA`tkmD*aj7|L`K&`$9AdLzRSN8J!0a7@ z{L>40&vW_u=QF~$BNsDT_R9Q~?A7_JxnJcS`&S*k%Z}bpeM^qs702nNU;Hw|7M$({ zcdjPam+M@l7yA}F@6t<-{lJ){1G&LfyLZLzT^uOb?AhV@;f2%ho+pVw8;ryEE4#O7 z37=b?r#iXMJB3q6En7kdxnezZu!bd+vl_}9^Uyz&gJsgMK<+f_LctvF0U?-msl3j|eCR>(+nJtevb zTAmsqes%Gdekt)Mvs>sjVKxjup=J@+#2&h1(99be&3lm}LL545w^d25NF_D<97rVm@b z;k!0q8T7NhkXxucHFoF=>woFum_p>DoCGkckhhsK1VG&)N;3$MkYbB2OVZSdwhOa! zg>U-_gsF_cj(!#0=YjPO>s#`cke zo4PNPQxT7zMt|dNx*jPabVq$Q%u7-tJS|HSwn`FYt21%Zwn@^ZnQ**>aY)icOi?uw zRuf4{(w%0}eZBx}l(Pi9L;zU{*GZD9fvb&3VGW+pug$=#8F$JqHK4Pg}U&`}HQybHCce?W=$X)f8u6M*xZk zBvZ$Hd$BO3X{m$7S2|`%M|lEJ@T$D5Vmhf}3#25Fi^LL>n1$A_^&zekGu?fVfyB zq*Uw*$K%Oa2w*d+EQO;{Nc~k3)PRu$N0Nb`F7V>b=r3pfL{Zo$6&3bF?p>V%xXw@% z^-t8ZihA;>XWfKY)7;T(M}PfH=F;_N3#@r=}cg|_#a(x!q?Wgyf1--X)j$={sJ3r%UhV0Hkh!I}Qb1ry2xNxB$*s}fe{qLMu6tTr83ZaR=}I+SmCGGl|Mh{<{1$x$`;t7xk6d;S1r&#+lxURdS3m-+7R5e%%4 zQ;4(7oqhZ4D(hKhJ-JhhY_5OtQl9mtse-#XO|7810;1*|Z#%w0H4o0xz;rJ7P$iFn SFZP^j9B$(N(j*M;xBM5b6dV%( literal 0 HcmV?d00001 diff --git a/tests/helpers/mock-server.py b/tests/helpers/mock-server.py index 57249d6..94b6547 100644 --- a/tests/helpers/mock-server.py +++ b/tests/helpers/mock-server.py @@ -1,6 +1,13 @@ #!/usr/bin/env python3 import http.server, json, sys, os, threading +# Daemonize: detach from parent process group +if os.fork() > 0: + sys.exit(0) +os.setsid() +if os.fork() > 0: + sys.exit(0) + PORT = int(sys.argv[1]) CONFIG = sys.argv[2] REQ_FILE = sys.argv[3] -- 2.52.0 From ae0358956331d9053bda914dfcbe8498e6f11baf Mon Sep 17 00:00:00 2001 From: moilanik Date: Sat, 20 Jun 2026 14:19:19 +0300 Subject: [PATCH 7/9] =?UTF-8?q?python=20lis=C3=A4ys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.ci-cucumber | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.ci-cucumber b/Dockerfile.ci-cucumber index 1e5f974..a3ef79e 100644 --- a/Dockerfile.ci-cucumber +++ b/Dockerfile.ci-cucumber @@ -1,6 +1,6 @@ FROM node:22 RUN apt-get update -qq && \ - apt-get install -y -qq --no-install-recommends lsof jq && \ + apt-get install -y -qq --no-install-recommends lsof jq python3 && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ npm install -g @cucumber/cucumber -- 2.52.0 From 6a113659c850aa2a2fac139fa8f08c200c52da89 Mon Sep 17 00:00:00 2001 From: moilanik Date: Sat, 20 Jun 2026 14:26:08 +0300 Subject: [PATCH 8/9] isoja testimuutoksia --- .gitea/workflows/example-cucumber-tests.yml | 2 +- tests/check-version.bats | 1 + tests/dispatch-workflow.bats | 14 +++---- .../step_definitions/commit-status.steps.js | 10 +---- .../features/step_definitions/common.steps.js | 9 ++--- .../step_definitions/test-execution.steps.js | 24 ++++-------- tests/helpers/mock-api.sh | 39 +++++++++++-------- tests/helpers/mock-server.py | 7 ---- tests/publish-git-pages.bats | 7 +--- tests/report-status.bats | 6 +-- 10 files changed, 50 insertions(+), 69 deletions(-) diff --git a/.gitea/workflows/example-cucumber-tests.yml b/.gitea/workflows/example-cucumber-tests.yml index 9f467ae..a9ab2d6 100644 --- a/.gitea/workflows/example-cucumber-tests.yml +++ b/.gitea/workflows/example-cucumber-tests.yml @@ -8,7 +8,7 @@ on: cucumber-node-image: required: false type: string - default: gitea.app.keskikuja.site/niko/ci-cucumber:latest + default: gitea.app.keskikuja.site/niko/ci-cucumber:with-python secrets: GITEA_TOKEN: required: true diff --git a/tests/check-version.bats b/tests/check-version.bats index b34472d..f27f4cd 100644 --- a/tests/check-version.bats +++ b/tests/check-version.bats @@ -5,6 +5,7 @@ source "$BATS_TEST_DIRNAME/helpers/mock-api.sh" setup() { export GITEA_TOKEN=test-token export GIT_TAG_PREFIX="" + export SERVER_URL="http://localhost:18080" export REPO="niko/test" export SHA="abc123" rm -rf /tmp/build-ctx diff --git a/tests/dispatch-workflow.bats b/tests/dispatch-workflow.bats index dec53f5..3f271de 100644 --- a/tests/dispatch-workflow.bats +++ b/tests/dispatch-workflow.bats @@ -19,7 +19,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"completed","conclusion":"success"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" [ "$status" -eq 0 ] } @@ -31,7 +31,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"completed","conclusion":"failure"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" [ "$status" -eq 1 ] } @@ -43,7 +43,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"completed","conclusion":"cancelled"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" [ "$status" -eq 1 ] } @@ -61,7 +61,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"running"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" "0.001" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" "0.001" [ "$status" -eq 124 ] } @@ -70,7 +70,7 @@ teardown() { {"code":500} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" [ "$status" -eq 1 ] } @@ -81,7 +81,7 @@ teardown() { {"code":200,"body":{"id":1,"status":"completed","conclusion":"success"}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "$GITEA_API_URL" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "http://localhost:18080" "test-token-abc123" [ "$status" -eq 0 ] path=$(mock_get_first_request_path) [[ "$path" == *"/api/v1/repos/test-owner/test-repo/actions/workflows/test.yml/dispatches"* ]] @@ -126,7 +126,7 @@ teardown() { {"code":200,"body":{"workflow_runs":[]}} ]' mock_start - run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{}' "$GITEA_API_URL" "test-token-abc123" + run bash scripts/dispatch-workflow.sh "test-owner/test-repo" "test.yml" "main" '{}' "http://localhost:18080" "test-token-abc123" [ "$status" -eq 1 ] [[ "$output" == *"ERROR"* ]] } diff --git a/tests/features/step_definitions/commit-status.steps.js b/tests/features/step_definitions/commit-status.steps.js index 4df2e3a..3cbe24a 100644 --- a/tests/features/step_definitions/commit-status.steps.js +++ b/tests/features/step_definitions/commit-status.steps.js @@ -27,8 +27,7 @@ function bashQuiet(cmd) { } function runReportStatus(args) { - const apiUrl = process.env.GITEA_API_URL || 'http://localhost:18080'; - return bash(`export GITEA_API_URL="${apiUrl}" GITEA_TOKEN="test-token-abc123" GIT_PAGES_URL="https://reports.example.com" GITHUB_REPOSITORY="test-owner/test-repo" GITHUB_SHA="abc123def456789012345678901234567890abcd" GITHUB_RUN_ID="42"; bash "${REPORT_SCRIPT}" ${args}`); + return bash(`export GITEA_API_URL="http://localhost:18080" GITEA_TOKEN="test-token-abc123" GIT_PAGES_URL="https://reports.example.com" GITHUB_REPOSITORY="test-owner/test-repo" GITHUB_SHA="abc123def456789012345678901234567890abcd" GITHUB_RUN_ID="42"; bash "${REPORT_SCRIPT}" ${args}`); } function getMockBody() { @@ -111,12 +110,7 @@ Then('the source commit shows the deployment status alongside the build status', When('a build step tries to report status but the build system is unavailable', function () { bashQuiet(`source "${MOCK_SCRIPT}" && mock_stop`); execSync('sleep 0.3', { stdio: 'ignore' }); - const out = execSync(`bash -o pipefail -c 'source "${MOCK_SCRIPT}" && mock_set_response 500 && mock_start >&2 && echo "$GITEA_API_URL"'`, { - cwd: PROJECT_ROOT, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - process.env.GITEA_API_URL = out.trim(); + bashQuiet(`source "${MOCK_SCRIPT}" && mock_set_response 500 && mock_start`); const r = runReportStatus('success "Should fail"'); this.reportStatusFailed = (r.status !== 0); }); diff --git a/tests/features/step_definitions/common.steps.js b/tests/features/step_definitions/common.steps.js index 6460bd7..6c665a8 100644 --- a/tests/features/step_definitions/common.steps.js +++ b/tests/features/step_definitions/common.steps.js @@ -6,16 +6,15 @@ const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); const MOCK_SCRIPT = path.join(PROJECT_ROOT, 'tests', 'helpers', 'mock-api.sh'); Before({ tags: '@mock' }, function () { - const out = execSync(`bash -o pipefail -c 'source "${MOCK_SCRIPT}" && mock_start >&2 && echo "$GITEA_API_URL"'`, { + const out = execSync(`bash -c 'source "${MOCK_SCRIPT}" && mock_start && sleep 1 && curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:18080/api/v1/repos/health/check'`, { cwd: PROJECT_ROOT, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); - const apiUrl = out.trim(); - if (!apiUrl.startsWith('http')) { - throw new Error(`Mock server failed to start (no API URL: ${apiUrl})`); + const trimmed = out.trim(); + if (!trimmed.startsWith('2') && !trimmed.startsWith('4')) { + throw new Error(`Mock server failed to start (HTTP ${trimmed})`); } - process.env.GITEA_API_URL = apiUrl; }); After({ tags: '@mock' }, function () { diff --git a/tests/features/step_definitions/test-execution.steps.js b/tests/features/step_definitions/test-execution.steps.js index 100af60..8657eb7 100644 --- a/tests/features/step_definitions/test-execution.steps.js +++ b/tests/features/step_definitions/test-execution.steps.js @@ -21,15 +21,9 @@ function bash(cmd) { } } -function getFreePort() { - const out = execSync(`python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()"`, { encoding: 'utf-8' }); - return parseInt(out.trim(), 10); -} - function setupMock(seqJson) { - const port = getFreePort(); - process.env.MOCK_PORT = String(port); - process.env.GITEA_API_URL = `http://localhost:${port}`; + execSync('lsof -ti :18080 2>/dev/null | xargs -r kill -9 2>/dev/null || true', { stdio: 'ignore' }); + execSync('sleep 0.4', { stdio: 'ignore' }); const seqFile = path.join(os.tmpdir(), `cucumber_seq_${Date.now()}.json`); fs.writeFileSync(seqFile, seqJson); @@ -40,16 +34,17 @@ function setupMock(seqJson) { const configFile = path.join(os.tmpdir(), `cucumber_cfg_${Date.now()}.txt`); fs.writeFileSync(configFile, `SEQUENCE\n${seqJson}\n${idxFile}`); - const proc = spawn('python3', [MOCK_SERVER, String(port), configFile, reqFile], { + const proc = spawn('python3', [MOCK_SERVER, '18080', configFile, reqFile], { cwd: PROJECT_ROOT, detached: true, stdio: 'ignore', }); proc.unref(); + this._mockProc = proc; for (let i = 0; i < 20; i++) { try { - execSync(`nc -z localhost ${port}`, { stdio: 'ignore' }); + execSync('curl -s --max-time 1 http://localhost:18080/', { stdio: 'ignore' }); fs.writeFileSync(idxFile, '0'); return; } catch (_) {} @@ -74,8 +69,7 @@ When('a test workflow is dispatched to a test project', function () { { code: 200, body: { workflow_runs: [{ id: 1, status: 'running' }] } }, { code: 200, body: { id: 1, status: 'completed', conclusion: 'success' } }, ])); - const url = process.env.GITEA_API_URL; - const r = runDispatch(`"test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "${url}" "test-token-abc123"`); + const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123"'); this.dispatchResult = r.status; }); @@ -93,8 +87,7 @@ When('a test workflow is dispatched and the tests fail', function () { { code: 200, body: { workflow_runs: [{ id: 1, status: 'running' }] } }, { code: 200, body: { id: 1, status: 'completed', conclusion: 'failure' } }, ])); - const url = process.env.GITEA_API_URL; - const r = runDispatch(`"test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "${url}" "test-token-abc123"`); + const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123"'); this.dispatchResult = r.status; }); @@ -110,8 +103,7 @@ When('a test workflow is dispatched but does not finish within the allowed time' { code: 200, body: { id: 1, status: 'running' } }, { code: 200, body: { id: 1, status: 'running' } }, ])); - const url = process.env.GITEA_API_URL; - const r = runDispatch(`"test-owner/test-repo" "test.yml" "main" '{"version":"1.2.3"}' "${url}" "test-token-abc123" "0.001"`); + const r = runDispatch('"test-owner/test-repo" "test.yml" "main" \'{"version":"1.2.3"}\' "http://localhost:18080" "test-token-abc123" "0.001"'); this.dispatchResult = r.status; }); diff --git a/tests/helpers/mock-api.sh b/tests/helpers/mock-api.sh index f7db2cd..e6ad4b5 100644 --- a/tests/helpers/mock-api.sh +++ b/tests/helpers/mock-api.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -MOCK_PORT="" +MOCK_PORT=18080 MOCK_PID="" MOCK_REQUEST_FILE="" MOCK_RESPONSE_CODE=201 @@ -9,20 +9,29 @@ MOCK_STATE_FILE="/tmp/mock_api_state" MOCK_SEQUENCE_FILE="" MOCK_CONFIG_FILE="" -_free_port() { - python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()" +_kill_port() { + local pids + pids=$(lsof -ti ":$MOCK_PORT" 2>/dev/null) || true + if [ -n "$pids" ]; then + kill -9 $pids 2>/dev/null || true + sleep 0.5 + fi +} + +_wait_port_free() { + local i=0 + while lsof -ti ":$MOCK_PORT" >/dev/null 2>&1 && [ $i -lt 50 ]; do + sleep 0.1 + i=$((i + 1)) + done } _wait_port_ready() { local i=0 - while [ $i -lt 30 ]; do - if nc -z localhost "$MOCK_PORT" 2>/dev/null; then - return 0 - fi - sleep 0.1 + while ! lsof -ti ":$MOCK_PORT" >/dev/null 2>&1 && [ $i -lt 30 ]; do + sleep 0.2 i=$((i + 1)) done - return 1 } mock_set_sequence() { @@ -36,12 +45,6 @@ mock_clear_sequence() { } mock_start() { - MOCK_PORT=$(_free_port) - export MOCK_PORT - MOCK_URL="http://localhost:${MOCK_PORT}" - export SERVER_URL="$MOCK_URL" - export GITEA_API_URL="$MOCK_URL" - MOCK_RESPONSE_CODE="${MOCK_RESPONSE_CODE:-201}" MOCK_REQUEST_FILE=$(mktemp) echo "$MOCK_REQUEST_FILE" > "$MOCK_STATE_FILE" @@ -56,15 +59,19 @@ mock_start() { echo "$MOCK_RESPONSE_CODE" >> "$MOCK_CONFIG_FILE" fi + _kill_port + _wait_port_free + nohup python3 "$(dirname "${BASH_SOURCE[0]}")/mock-server.py" "$MOCK_PORT" "$MOCK_CONFIG_FILE" "$MOCK_REQUEST_FILE" \ /dev/null 2>&1 & - disown MOCK_PID=$! _wait_port_ready } mock_stop() { [ -n "${MOCK_PID:-}" ] && kill -9 "$MOCK_PID" 2>/dev/null || true + _kill_port + _wait_port_free [ -n "${MOCK_REQUEST_FILE:-}" ] && rm -f "${MOCK_REQUEST_FILE}" 2>/dev/null || true [ -n "${MOCK_SEQUENCE_FILE:-}" ] && rm -f "${MOCK_SEQUENCE_FILE}" 2>/dev/null || true [ -n "${MOCK_SEQUENCE_FILE:-}" ] && rm -f "${MOCK_SEQUENCE_FILE}.idx" 2>/dev/null || true diff --git a/tests/helpers/mock-server.py b/tests/helpers/mock-server.py index 94b6547..57249d6 100644 --- a/tests/helpers/mock-server.py +++ b/tests/helpers/mock-server.py @@ -1,13 +1,6 @@ #!/usr/bin/env python3 import http.server, json, sys, os, threading -# Daemonize: detach from parent process group -if os.fork() > 0: - sys.exit(0) -os.setsid() -if os.fork() > 0: - sys.exit(0) - PORT = int(sys.argv[1]) CONFIG = sys.argv[2] REQ_FILE = sys.argv[3] diff --git a/tests/publish-git-pages.bats b/tests/publish-git-pages.bats index 9723622..1e62214 100644 --- a/tests/publish-git-pages.bats +++ b/tests/publish-git-pages.bats @@ -63,10 +63,9 @@ teardown() { {"code":200,"body":"published"} ]' mock_start - export GIT_PAGES_URL="http://localhost:${MOCK_PORT}" run bash scripts/publish-git-pages.sh "unit-tests" [ "$status" -eq 0 ] - [[ "$output" == "${GIT_PAGES_URL}/test-owner/test-repo/reports/abc123de" ]] + [[ "$output" == "http://localhost:18080/test-owner/test-repo/reports/abc123de" ]] } @test "publish with suite subpath" { @@ -76,10 +75,9 @@ teardown() { {"code":200,"body":"published"} ]' mock_start - export GIT_PAGES_URL="http://localhost:${MOCK_PORT}" run bash scripts/publish-git-pages.sh "sub/suite" [ "$status" -eq 0 ] - [[ "$output" == "${GIT_PAGES_URL}/test-owner/test-repo/reports/abc123de" ]] + [[ "$output" == "http://localhost:18080/test-owner/test-repo/reports/abc123de" ]] } @test "git-pages returns HTTP 500 → exit 1" { @@ -87,7 +85,6 @@ teardown() { {"code":500,"body":"internal error"} ]' mock_start - export GIT_PAGES_URL="http://localhost:${MOCK_PORT}" run bash scripts/publish-git-pages.sh "unit-tests" [ "$status" -eq 1 ] [[ "$output" == *"500"* ]] diff --git a/tests/report-status.bats b/tests/report-status.bats index c29a5d2..786c731 100644 --- a/tests/report-status.bats +++ b/tests/report-status.bats @@ -23,8 +23,7 @@ teardown() { body=$(mock_get_request_body) [[ "$body" == *'"state":"pending"'* ]] [[ "$body" == *'"description":"Building project"'* ]] - expected_url="${GITEA_API_URL}/test-owner/test-repo/actions/runs/42" - [[ "$body" == *"\"target_url\":\"${expected_url}\""* ]] + [[ "$body" == *'"target_url":"http://localhost:18080/test-owner/test-repo/actions/runs/42"'* ]] method=$(mock_get_request_method) [[ "$method" == "POST" ]] } @@ -45,8 +44,7 @@ teardown() { [ "$status" -eq 0 ] body=$(mock_get_request_body) [[ "$body" == *'"state":"failure"'* ]] - expected_url="${GITEA_API_URL}/test-owner/test-repo/actions/runs/42" - [[ "$body" == *"\"target_url\":\"${expected_url}\""* ]] + [[ "$body" == *'"target_url":"http://localhost:18080/test-owner/test-repo/actions/runs/42"'* ]] } @test "default key when not provided" { -- 2.52.0 From 1cd62562e0ad395c69525b045edbc67afa100e96 Mon Sep 17 00:00:00 2001 From: moilanik Date: Sat, 20 Jun 2026 14:33:34 +0300 Subject: [PATCH 9/9] ci kontin testiohje --- skills/ci-container-build/SKILL.md | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/skills/ci-container-build/SKILL.md b/skills/ci-container-build/SKILL.md index e19f598..04d5db8 100644 --- a/skills/ci-container-build/SKILL.md +++ b/skills/ci-container-build/SKILL.md @@ -182,6 +182,42 @@ vain build-vaiheessa — `apk del curl` poistaa työkalun ennen runtimea. Tapa C pre-cacheaa kielikohtaiset riippuvuudet ja tuottaa täysin offline-runtime-kontin. +## Testaus ennen julkaisua + +Konttia ei saa pushata registryyn ennen kuin se on validoitu. + +### 1. Aja testit kontin sisällä + +Testit on ajettava **kontin sisällä**, ei suoraan lokaalilla koneella. + +```bash +# OIKEIN — kontin sisällä +docker build -t ci-tyokalu:test . +docker run --rm -v "$(pwd):/repo" -w /repo ci-tyokalu:test bash -c 'bats tests/' + +# VÄÄRIN — lokaalit binäärit vs kontti +bats tests/ # eri bash/työkalut kuin kontissa +bashcov -- bats tests/ # eri ruby-versio kuin kontissa +``` + +Lokaali ympäristö (macOS, eri kirjastoversiot) poikkeaa aina kontista. +Testi voi mennä läpi lokaalissa mutta failata CI:ssä, tai päinvastoin. + +### 2. Fragile-testien seulonta (10x ajo) + +Aja koko testipaketti **10 kertaa peräkkäin** kontin sisällä ennen pushausta: + +```bash +for i in $(seq 1 10); do + echo "=== RUN $i ===" + docker run --rm -v "$(pwd):/repo" -w /repo ci-tyokalu:test \ + bash -c 'bats tests/' || exit 1 +done +``` + +Jos yksikin ajo failaa, kontissa on fragile testi — korjaa ennen pushausta. +Fragile testit syövät devaukseen käytettyä aikaa turhilla uusinta-ajoilla. + ## Mitä EI kannata tehdä - Älä lisää `workflow_call`-triggariä — CI-konttia ei koskaan buildata automaattisesti -- 2.52.0