From f5f9d1f8a592bd888e658192b570e2b2650aed30 Mon Sep 17 00:00:00 2001 From: Andreas Niemann Date: Wed, 1 Jul 2026 01:03:55 +0200 Subject: [PATCH 1/2] feat(ci-image): bake ecosystem CI toolchain (lint + release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the toolchain the reusable ecosystem workflows (999_testing) need, so jobs don't install it per run: shellcheck + yamllint (apt), eslint (global), and semantic-release with the conventionalcommits PRESET + @semantic-release/ git + changelog — the plugin set Olsitec's GitLab release template uses (olsitec/gitlab ci_templates/release-automation/semantic-release.yaml). Pinned in VERSIONS for traceability (NOT in preflight's up-gating tool set — these are downstream-job tools, not foundation-deploy tools). Rebuild the image on the VM after this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSIONS | 12 ++++++++++++ containers/ci-image/Dockerfile | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/VERSIONS b/VERSIONS index 527f8e3..f703028 100644 --- a/VERSIONS +++ b/VERSIONS @@ -120,3 +120,15 @@ TOOL_OPENSSH_MIN=8.0 # --- S3 / RustFS client (bucket ops, backup put/get). MinIO client `mc`. --- TOOL_MC_MIN=2023.01.01 + +# ----------------------------------------------------------------------------- +# ECOSYSTEM CI TOOLCHAIN (999_testing — reusable lint/release workflows) +# Baked into the foundation-ci image (containers/ci-image/Dockerfile), NOT +# part of preflight's `up`-gating tool set (these are job tools for downstream +# projects, not foundation-deploy tools). Pinned here for traceability; the +# eslint/semantic-release pins mirror the Dockerfile ARGs. +# ----------------------------------------------------------------------------- +TOOL_SHELLCHECK_MIN=0.9.0 # apt (debian bookworm) +TOOL_YAMLLINT_MIN=1.26.0 # apt (debian bookworm) +TOOL_ESLINT_MIN=9.18.0 # npm -g (Dockerfile ESLINT_VERSION) +TOOL_SEMANTIC_RELEASE_MIN=24.2.3 # npm -g (Dockerfile SEMANTIC_RELEASE_VERSION) diff --git a/containers/ci-image/Dockerfile b/containers/ci-image/Dockerfile index 00dc17a..bf7eda2 100644 --- a/containers/ci-image/Dockerfile +++ b/containers/ci-image/Dockerfile @@ -59,6 +59,32 @@ RUN set -eux; \ curl -fsSL "https://dl.min.io/client/mc/release/linux-${TARGETARCH}/archive/mc.${MC_RELEASE}" -o /usr/local/bin/mc; \ chmod +x /usr/local/bin/mc; mc --version +# --- ecosystem CI toolchain (999_testing): linters + release tooling ----------------- +# shellcheck + yamllint from apt; eslint + semantic-release as pinned global npm installs +# so the reusable lint/semantic-release workflows have a toolchain even for projects that +# do not vendor their own (projects MAY still `bunx`/`npx` a pinned local version, which +# wins). NOT part of preflight's `up`-gating tool set — these are job tools, not deploy +# tools — but pinned in VERSIONS for traceability. +ARG ESLINT_VERSION=9.18.0 +ARG SEMANTIC_RELEASE_VERSION=24.2.3 +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends shellcheck yamllint; \ + rm -rf /var/lib/apt/lists/*; \ + shellcheck --version; yamllint --version +# semantic-release + the plugin set Olsitec's release config uses (olsitec/gitlab +# ci_templates/release-automation/semantic-release.yaml): the conventionalcommits +# PRESET (not bundled) drives the releaseRules; git/changelog support real releases. +# Installed in the SAME global root so semantic-release resolves them by name. +RUN set -eux; \ + npm install -g \ + "eslint@${ESLINT_VERSION}" \ + "semantic-release@${SEMANTIC_RELEASE_VERSION}" \ + conventional-changelog-conventionalcommits@8.0.0 \ + @semantic-release/git@10.0.1 \ + @semantic-release/changelog@6.0.3; \ + eslint --version; semantic-release --version + # Forgejo Actions overrides the entrypoint with its job script; keep a sane default. WORKDIR /workspace CMD ["bash"] From f9aecf1b184b3b7344be879dfca3b0a735fdd06b Mon Sep 17 00:00:00 2001 From: Andreas Niemann Date: Wed, 1 Jul 2026 01:03:56 +0200 Subject: [PATCH 2/2] feat(ci): reusable ecosystem workflows + selftest (999_testing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ecosystem-CI architecture: reusable Forgejo workflows (on: workflow_call) that downstream repos reference as `uses: olsitec/foundation/.forgejo/workflows/.yml@master`. - reusable-node-build.yml: install + build for npm/bun/none — covers the npm package (olsicrypto), bun package (document-engine), and no-artifact versioned (olsitrack/api) shapes. - reusable-docker-build.yml: docker build via the host socket (R5: trusted repos only until the runner is fenced) — the seaspots-homepage / token-service shape. - reusable-lint.yml: eslint + yamllint gate (either error → job non-zero). - reusable-semantic-release.yml: conventionalcommits-preset version probe (dry-run), faithful to the GitLab template; outputs the computed next version. Real Forgejo publishing deferred (no @semantic-release/forgejo analogue yet). - ecosystem-selftest.yml + ci/semantic-release-bumptest.sh: self-contained proof on the runner of the 999_testing acceptance criteria that need no external repo — the semantic-release bump sequence (1.0.0→1.1.0→1.1.1→2.0.0→3.0.0) and the eslint/yamllint non-zero-exit gates. Validated in a foundation-ci container. Co-Authored-By: Claude Opus 4.8 (1M context) --- .forgejo/workflows/ecosystem-selftest.yml | 66 ++++++++++ .forgejo/workflows/reusable-docker-build.yml | 67 ++++++++++ .forgejo/workflows/reusable-lint.yml | 63 ++++++++++ .forgejo/workflows/reusable-node-build.yml | 58 +++++++++ .../workflows/reusable-semantic-release.yml | 81 ++++++++++++ ci/semantic-release-bumptest.sh | 115 ++++++++++++++++++ 6 files changed, 450 insertions(+) create mode 100644 .forgejo/workflows/ecosystem-selftest.yml create mode 100644 .forgejo/workflows/reusable-docker-build.yml create mode 100644 .forgejo/workflows/reusable-lint.yml create mode 100644 .forgejo/workflows/reusable-node-build.yml create mode 100644 .forgejo/workflows/reusable-semantic-release.yml create mode 100755 ci/semantic-release-bumptest.sh diff --git a/.forgejo/workflows/ecosystem-selftest.yml b/.forgejo/workflows/ecosystem-selftest.yml new file mode 100644 index 0000000..98c431d --- /dev/null +++ b/.forgejo/workflows/ecosystem-selftest.yml @@ -0,0 +1,66 @@ +# ecosystem-selftest — proves the foundation's ecosystem-CI capabilities on its own +# runner (documentation/999_testing.md), without depending on external candidate +# repos. Three self-contained jobs, each asserting an acceptance criterion: +# - semantic-release: the bump sequence 1.0.0→1.1.0→1.1.1→2.0.0→3.0.0 +# - eslint-gate: an eslint error makes the job exit non-zero +# - yamllint-gate: a yamllint error makes the job exit non-zero +# Build-shape coverage (npm/bun/docker) is exercised by the reusable-* workflows +# against the real candidate repos; this file guards the capabilities that need no +# external repo. Runs in the baked foundation-ci image. +name: ecosystem-selftest +on: + push: + paths: + - "ci/**" + - ".forgejo/workflows/ecosystem-selftest.yml" + - ".forgejo/workflows/reusable-*.yml" + workflow_dispatch: + +jobs: + semantic-release-bumptest: + runs-on: docker + container: + image: foundation-ci:latest + steps: + - uses: actions/checkout@v4 + - name: semantic-release bump sequence + run: ./ci/semantic-release-bumptest.sh + + eslint-gate: + runs-on: docker + container: + image: foundation-ci:latest + steps: + - uses: actions/checkout@v4 + - name: an eslint error must fail the job (exit non-zero) + run: | + set -e + d=$(mktemp -d); cd "$d" + # flat config (eslint 9) with no-unused-vars as an error + cat > eslint.config.mjs <<'EOF' + export default [{ rules: { "no-unused-vars": "error" } }]; + EOF + printf 'const x = 1;\n' > bad.js # x is unused → error + if eslint bad.js; then + echo "BUG: eslint passed on a file with an error"; exit 1 + else + echo "OK: eslint exited non-zero on the lint error" + fi + + yamllint-gate: + runs-on: docker + container: + image: foundation-ci:latest + steps: + - uses: actions/checkout@v4 + - name: a yamllint error must fail the job (exit non-zero) + run: | + set -e + d=$(mktemp -d); cd "$d" + # duplicate key + bad indentation → yamllint error + printf 'a: 1\na: 2\n' > bad.yaml + if yamllint -d '{extends: default, rules: {document-start: disable}}' bad.yaml; then + echo "BUG: yamllint passed on a file with a duplicate key"; exit 1 + else + echo "OK: yamllint exited non-zero on the lint error" + fi diff --git a/.forgejo/workflows/reusable-docker-build.yml b/.forgejo/workflows/reusable-docker-build.yml new file mode 100644 index 0000000..6d5dbea --- /dev/null +++ b/.forgejo/workflows/reusable-docker-build.yml @@ -0,0 +1,67 @@ +# reusable-docker-build — build a Docker image (999_testing candidates C1/C5). +# +# A REUSABLE workflow (on: workflow_call) downstream repos call: +# jobs: +# image: +# uses: olsitec/foundation/.forgejo/workflows/reusable-docker-build.yml@master +# with: { image: "olsitec/seaspots-homepage:ci", push: false } +# +# Builds against the HOST Docker daemon via the mounted socket (the foundation-ci +# image ships the docker CLI; the runner's valid_volumes allows the mount). NOTE +# (R5): the host socket is root-equivalent on the forge VM — this is acceptable +# ONLY for trusted first-party repos until the runner is fenced to its own VM. +# +# Candidates C1 (seaspots-homepage) and C5 (token-service) depend on @olsitec +# packages from a private registry that is not published yet (Stage-2). Their real +# builds need a registry / npmrc; this workflow proves the docker-build path and +# accepts a `build-args`/`npmrc` hook for when the registry exists. +name: reusable-docker-build +on: + workflow_call: + inputs: + context: + type: string + default: "." + dockerfile: + type: string + default: "Dockerfile" + image: + description: "image ref to tag, e.g. name:tag" + type: string + required: true + build-args: + description: "newline-separated KEY=VALUE docker --build-arg pairs" + type: string + default: "" + push: + description: "push to the foundation registry after build (registry must exist)" + type: boolean + default: false + +jobs: + image: + runs-on: docker + container: + image: foundation-ci:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + steps: + - uses: actions/checkout@v4 + + - name: Docker build + run: | + args="" + if [ -n "${{ inputs.build-args }}" ]; then + while IFS= read -r kv; do + [ -z "$kv" ] && continue + args="$args --build-arg $kv" + done <<'EOF' + ${{ inputs.build-args }} + EOF + fi + echo "+ docker build -f ${{ inputs.dockerfile }} -t ${{ inputs.image }} $args ${{ inputs.context }}" + docker build -f "${{ inputs.dockerfile }}" -t "${{ inputs.image }}" $args "${{ inputs.context }}" + + - name: Push + if: ${{ inputs.push }} + run: docker push "${{ inputs.image }}" diff --git a/.forgejo/workflows/reusable-lint.yml b/.forgejo/workflows/reusable-lint.yml new file mode 100644 index 0000000..81bbb20 --- /dev/null +++ b/.forgejo/workflows/reusable-lint.yml @@ -0,0 +1,63 @@ +# reusable-lint — eslint + yamllint gate (999_testing "linter testing"). +# +# A REUSABLE workflow (on: workflow_call). Either linter finding an error makes +# the job exit non-zero (the acceptance criterion). Prefers the project's own +# pinned eslint (node_modules/.bin) for config/plugin fidelity, falling back to +# the foundation-ci image's global eslint; yamllint comes from the image. +# +# jobs: +# lint: +# uses: olsitec/foundation/.forgejo/workflows/reusable-lint.yml@master +# with: { eslint-paths: ".", yamllint-paths: "." } +name: reusable-lint +on: + workflow_call: + inputs: + eslint: + type: boolean + default: true + yamllint: + type: boolean + default: true + eslint-paths: + type: string + default: "." + yamllint-paths: + type: string + default: "." + package-manager: + description: "bun | npm | none — to install project-local eslint config/plugins" + type: string + default: bun + +jobs: + lint: + runs-on: docker + container: + image: foundation-ci:latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies (project-local eslint config/plugins) + if: ${{ inputs.eslint }} + run: | + case "${{ inputs.package-manager }}" in + bun) bun install --frozen-lockfile || bun install || true ;; + npm) npm ci || npm install || true ;; + none) echo "skip install" ;; + esac + + - name: eslint + if: ${{ inputs.eslint }} + run: | + if [ -x node_modules/.bin/eslint ]; then + echo "+ project eslint"; node_modules/.bin/eslint ${{ inputs.eslint-paths }} + else + echo "+ image eslint"; eslint ${{ inputs.eslint-paths }} + fi + + - name: yamllint + if: ${{ inputs.yamllint }} + run: | + echo "+ yamllint ${{ inputs.yamllint-paths }}" + yamllint ${{ inputs.yamllint-paths }} diff --git a/.forgejo/workflows/reusable-node-build.yml b/.forgejo/workflows/reusable-node-build.yml new file mode 100644 index 0000000..4155b89 --- /dev/null +++ b/.forgejo/workflows/reusable-node-build.yml @@ -0,0 +1,58 @@ +# reusable-node-build — build/test an npm- or bun-based project (999_testing). +# +# A REUSABLE workflow (on: workflow_call) downstream repos call: +# jobs: +# build: +# uses: olsitec/foundation/.forgejo/workflows/reusable-node-build.yml@master +# with: { package-manager: bun, build: "bun run build" } +# +# Runs in the baked foundation-ci image (bun + node present). Covers the +# non-Docker candidate shapes: npm package built with npm (olsicrypto), bun +# package built with bun (document-engine), and the no-build / versioned-only +# utility (olsitrack/api) via an empty `build`. +name: reusable-node-build +on: + workflow_call: + inputs: + package-manager: + description: "bun | npm | none (none = skip install)" + type: string + default: bun + build: + description: "build command to run verbatim (empty = skip, e.g. no-artifact repos)" + type: string + default: "" + workdir: + description: "working directory for install + build" + type: string + default: "." + +jobs: + build: + runs-on: docker + container: + image: foundation-ci:latest + defaults: + run: + working-directory: ${{ inputs.workdir }} + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies (${{ inputs.package-manager }}) + run: | + case "${{ inputs.package-manager }}" in + bun) bun install --frozen-lockfile || bun install ;; + npm) npm ci || npm install ;; + none) echo "package-manager=none → skipping install" ;; + *) echo "unknown package-manager '${{ inputs.package-manager }}'" >&2; exit 1 ;; + esac + + - name: Build + run: | + cmd='${{ inputs.build }}' + if [ -z "$cmd" ]; then + echo "no build command (non-artifact / versioned-only repo) — install-only check passed" + exit 0 + fi + echo "+ $cmd" + eval "$cmd" diff --git a/.forgejo/workflows/reusable-semantic-release.yml b/.forgejo/workflows/reusable-semantic-release.yml new file mode 100644 index 0000000..01e3660 --- /dev/null +++ b/.forgejo/workflows/reusable-semantic-release.yml @@ -0,0 +1,81 @@ +# reusable-semantic-release — compute the next semver from conventional commits +# (999_testing "semantic-release testing"). Mirrors the canonical GitLab template +# (olsitec/gitlab ci_templates/release-automation/semantic-release.yaml): the +# conventionalcommits preset + Olsitec's releaseRules, run as a `--dry-run --no-ci +# --tag-format '${version}'` version probe. Exposes the computed version as an output. +# +# jobs: +# version: +# uses: olsitec/foundation/.forgejo/workflows/reusable-semantic-release.yml@master +# build: +# needs: version +# runs-on: docker +# steps: [ run: echo "releasing ${{ needs.version.outputs.version }}" ] +# +# NOTE: dry-run only — it computes/prints the next version (the part exercised by +# 999_testing and the GitLab `generate-release-version` job). Actually PUBLISHING a +# release to Forgejo (tag + release + changelog) needs a Forgejo-side publish step +# and a token; that is deferred until the package/release flow is wired (the GitLab +# template publishes via @semantic-release/gitlab, which has no Forgejo analogue yet). +name: reusable-semantic-release +on: + workflow_call: + inputs: + branch: + type: string + default: master + outputs: + version: + description: "next release version (empty if the commits warrant no release)" + value: ${{ jobs.version.outputs.version }} + +jobs: + version: + runs-on: docker + container: + image: foundation-ci:latest + outputs: + version: ${{ steps.compute.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # semantic-release needs full history + tags + + - name: Write .releaserc.yaml (Olsitec conventionalcommits ruleset) + run: | + cat > .releaserc.yaml <<'EOF' + branches: + - name: ${{ inputs.branch }} + tagFormat: "${version}" + plugins: + - - "@semantic-release/commit-analyzer" + - preset: conventionalcommits + releaseRules: + - { breaking: true, release: major } + - { type: breaking, release: major } + - { type: feature, release: minor } + - { type: feat, release: minor } + - { type: fix, release: patch } + - { type: build, release: patch } + - { type: chore, release: patch } + - { type: ci, release: patch } + - { type: docs, release: patch } + - { type: perf, release: patch } + - { type: refactor, release: patch } + - { type: style, release: patch } + - { type: test, release: patch } + parserOpts: + noteKeywords: [ "BREAKING CHANGE", "BREAKING CHANGES" ] + - "@semantic-release/release-notes-generator" + EOF + + - name: Compute next version (dry-run) + id: compute + run: | + out=$(semantic-release --dry-run --no-ci --tag-format '${version}' --branches "${{ inputs.branch }}" 2>&1 || true) + printf '%s\n' "$out" + ver=$(printf '%s\n' "$out" \ + | grep -oiE 'next release version is [0-9]+\.[0-9]+\.[0-9]+' \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | tail -1) + echo "computed next version: ${ver:-}" + echo "version=$ver" >> "$GITHUB_OUTPUT" diff --git a/ci/semantic-release-bumptest.sh b/ci/semantic-release-bumptest.sh new file mode 100755 index 0000000..3a66955 --- /dev/null +++ b/ci/semantic-release-bumptest.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# semantic-release-bumptest.sh — prove semantic-release computes the version bumps +# Olsitec expects (documentation/999_testing.md "semantic-release testing"). +# +# Uses the SAME releaserc + `--dry-run --no-ci --tag-format '${version}'` technique +# as the canonical GitLab template (olsitec/gitlab ci_templates/release-automation/ +# semantic-release.yaml): conventionalcommits preset + the custom releaseRules +# (feat→minor, fix/chore→patch, breaking→major, BREAKING CHANGE/BREAKING CHANGES +# footers→major). Self-contained — builds a throwaway repo + bare origin, walks the +# conventional-commit sequence, and asserts: +# init → 1.0.0 · feat → 1.1.0 · fix/chore → 1.1.1 · feat! → 2.0.0 · BREAKING → 3.0.0 +# Exits non-zero on the first mismatch. Runs in the foundation-ci image (which bakes +# semantic-release + the conventionalcommits preset). +set -euo pipefail + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +REPO="$WORK/repo" +BARE="$WORK/origin.git" +BRANCH=master + +git init -q --bare "$BARE" +git init -q -b "$BRANCH" "$REPO" +cd "$REPO" +git config user.email ci@olsitec.de +git config user.name "CI Bumptest" +git remote add origin "$BARE" + +# The releaserc Olsitec uses (trimmed to the version-COMPUTE plugins: the host +# publish plugins are irrelevant to a dry-run bump check and would need a token). +cat > .releaserc.yaml <<'EOF' +branches: + - name: master +tagFormat: "${version}" +plugins: + - - "@semantic-release/commit-analyzer" + - preset: conventionalcommits + releaseRules: + - breaking: true + release: major + - type: breaking + release: major + - type: feature + release: minor + - type: feat + release: minor + - type: fix + release: patch + - type: build + release: patch + - type: chore + release: patch + - type: ci + release: patch + - type: docs + release: patch + - type: perf + release: patch + - type: refactor + release: patch + - type: style + release: patch + - type: test + release: patch + parserOpts: + noteKeywords: + - BREAKING CHANGE + - BREAKING CHANGES + - "@semantic-release/release-notes-generator" +EOF +git add -A +git commit -q -m "chore: scaffold releaserc" + +# Echo the version semantic-release would publish next (dry-run), or "" if none. +compute() { + out=$(semantic-release --dry-run --no-ci --tag-format '${version}' --branches "$BRANCH" 2>&1 || true) + # semantic-release logs e.g. "The next release version is 1.1.0" + printf '%s\n' "$out" \ + | grep -oiE 'next release version is [0-9]+\.[0-9]+\.[0-9]+' \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | tail -1 +} + +FAILED=0 +# step [body] +step() { + exp="$1"; subj="$2"; body="${3:-}" + if [ -n "$body" ]; then + git commit -q --allow-empty -m "$subj" -m "$body" + else + git commit -q --allow-empty -m "$subj" + fi + git push -q origin "$BRANCH" + got=$(compute) + if [ "$got" = "$exp" ]; then + echo " OK '$subj' → $got" + git tag "$got" # simulate the published release (bare tag) + git push -q origin "$got" + else + echo " FAIL '$subj' → expected $exp, got '${got:-}'" + FAILED=1 + fi +} + +echo "semantic-release bump sequence (999_testing):" +step 1.0.0 "feat: initial release" +step 1.1.0 "feat: add a feature" +step 1.1.1 "fix: correct a bug" +step 2.0.0 "feat!: breaking redesign" +step 3.0.0 "feat: another change" "BREAKING CHANGE: drops the old API" + +if [ "$FAILED" -ne 0 ]; then + echo "semantic-release bumptest: FAILED" + exit 1 +fi +echo "semantic-release bumptest: PASS (1.0.0 → 1.1.0 → 1.1.1 → 2.0.0 → 3.0.0)"