#!/usr/bin/env bash # Checks that Signage's CI cleans up every stack it starts, even when the runner kills a job (the # platform's rule: docs/development/security.md, "Disposable stacks never outlive their job"). # # bash ci/check-cleanup.sh # # The `integration` job is the one that starts Docker stacks. It must have its own # EKKLESIA_CI_JOB_KEY, sweep stale CI stacks before it starts anything, and remove its own in a final # `if: always()` step. The pinned platform smoke owns and labels every image and container. set -Eeuo pipefail root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" workflow="$root/.gitea/workflows/ci.yml" publish_workflow="$root/.gitea/workflows/publish-release.yml" failures=0 check() { if eval "$1"; then echo "ok $2" else echo "FAIL $2" failures=$((failures + 1)) fi } # The integration job's lines, from its key to the next job (or the end of the file). job="$(awk '/^ integration:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")" line_of() { grep -n -F -- "$1" <<<"$job" | head -1 | cut -d: -f1 || true; } stale="$(line_of 'ci-sweep.sh --stale')" run="$(line_of 'bash ci/integration.sh')" own="$(line_of 'ci-sweep.sh --job')" last_step="$(grep -n -- " - name:" <<<"$job" | tail -1 | cut -d: -f1 || true)" check '[[ -n "$job" ]]' "the workflow has an integration job" check '! grep -q "if: false" <<<"$job"' "the integration job is enabled" check 'grep -q "EKKLESIA_CI_JOB_KEY:" <<<"$job"' "the integration job names its stacks with its own key" check '[[ -n "$stale" && -n "$run" ]] && ((stale < run))' "it sweeps stale CI stacks before it starts anything" check '[[ -n "$own" && -n "$run" ]] && ((own > run)) && ((own > last_step))' \ "its last step removes its own stacks" check 'sed -n "${last_step},\$p" <<<"$job" | grep -q "if: always()"' "that last step always runs" check 'grep -q "EKKLESIA_SMOKE_MODULE_RELEASE_DIR" "$root/ci/integration.sh" && grep -q "bash scripts/smoke-deploy.sh" "$root/ci/integration.sh"' \ "the integration delegates its external package to the platform smoke" check 'grep -Eq "^EKKLESIA_PLATFORM_REF=refs/(pull/[0-9]+/head|heads/main)$" "$root/ci/platform.env" && grep -Eq "^EKKLESIA_PLATFORM_COMMIT=[0-9a-f]{40}$" "$root/ci/platform.env"' \ "the platform harness is pinned by full ref and commit" check 'grep -q "rev-parse refs/remotes/pinned" "$root/ci/fetch-platform.sh" && grep -q "does not resolve to EKKLESIA_PLATFORM_COMMIT" "$root/ci/fetch-platform.sh"' \ "the fetch verifies that the pinned ref resolves to the pinned commit" # Normal CI builds the canonical archive once and hands those exact bytes to integration through a # Gitea Actions v3 artifact. It must contain no release trigger, permission, or mutation path. package_job="$(awk '/^ package:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")" check 'grep -q "pull_request:" "$workflow" && grep -q "push:" "$workflow"' \ "normal CI still runs for pull requests and main pushes" check '! grep -q "workflow_dispatch:" "$workflow" && ! grep -q "tags:" "$workflow"' \ "normal CI has no manual-publish or tag trigger" check '! grep -q "contents: write" "$workflow" && ! grep -q "github.token" "$workflow"' \ "normal CI has no release-specific permission or token" check '! grep -q "release-assets.py publish" "$workflow" && ! grep -q "ekklesia-corresponding-source" "$workflow"' \ "normal CI cannot publish or prepare release assets" check 'grep -q "bash ci/package.sh" <<<"$package_job" && grep -q "actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de" <<<"$package_job"' \ "the package job builds and transfers one canonical artifact" check 'grep -q "actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b" <<<"$job" && ! grep -Eq "ci/package.sh|ekklesia-pack-module" <<<"$job"' \ "integration downloads that artifact and never rebuilds it" check 'grep -q "needs.package.outputs.sha256" <<<"$job" && grep -q "GITHUB_OUTPUT" "$root/ci/package.sh"' \ "integration binds the downloaded bytes to the package job's digest" # Publishing is an explicit operator action. One job validates its dispatch input and selected ref, # builds the package exactly once, integration-tests that directory, prepares redistribution files, # and only then gives the same directory to the Gitea publisher. gate_job="$(awk '/^ release-gate:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")" publish_job="$(awk '/^ publish:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")" check 'grep -q "workflow_dispatch:" "$publish_workflow" && ! grep -q "pull_request:" "$publish_workflow" && ! grep -q "push:" "$publish_workflow"' \ "publishing is workflow_dispatch only and cannot run for a PR or push" check 'grep -q "inputs:" "$publish_workflow" && grep -q " version:" "$publish_workflow" && grep -q "required: true" "$publish_workflow"' \ "manual publishing requires an explicit version input" check 'grep -q "refs/heads/main" <<<"$gate_job" && grep -q "packageVersion.*requested" <<<"$gate_job" && grep -q "moduleVersion.*requested" <<<"$gate_job"' \ "the publish gate requires selected main and validates both source versions" check '[[ "$(grep -c "bash ci/package.sh" "$publish_workflow")" -eq 1 ]]' \ "the publish workflow builds the canonical Module Package exactly once" check 'grep -q "ci/integration.sh.*build/release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$publish_job" && grep -q "release-assets.py publish" <<<"$publish_job"' \ "integration proves the package handed unchanged to the publisher" check 'grep -q "EXPECTED_PACKAGE_SHA256:.*steps.canonical.outputs.sha256" <<<"$gate_job" && grep -q "EXPECTED_PACKAGE_SHA256:.*needs.release-gate.outputs.sha256" <<<"$publish_job"' \ "the publish gate binds consumers to the canonical package digest" check 'grep -q "ekklesia-corresponding-source verify" <<<"$gate_job" && grep -q "needs: release-gate" <<<"$publish_job"' \ "corresponding-source verification finishes before the publishing job" check 'grep -q "GITHUB_TOKEN:.*github.token" <<<"$publish_job" && grep -q "contents: write" <<<"$publish_job" && ! grep -q "contents: write" <<<"$gate_job"' \ "only the final manual publishing job receives release write authority" # The manual workflow also owns a Docker integration stack, so it follows the same cleanup rule. publish_stale="$(grep -n -F -- 'ci-sweep.sh --stale' <<<"$gate_job" | cut -d: -f1 || true)" publish_run="$(grep -n -F -- 'bash ci/integration.sh' <<<"$gate_job" | cut -d: -f1 || true)" publish_own="$(grep -n -F -- 'ci-sweep.sh --job' <<<"$gate_job" | cut -d: -f1 || true)" check '[[ -n "$publish_stale" && -n "$publish_run" ]] && ((publish_stale < publish_run))' \ "manual publishing sweeps stale stacks before integration" check '[[ -n "$publish_own" && -n "$publish_run" ]] && ((publish_own > publish_run))' \ "manual publishing always has a post-integration cleanup step" ((failures == 0)) || { echo "check-cleanup: ${failures} check(s) failed." >&2 exit 1 }