Files
Signage/ci/check-cleanup.sh
ekklesia 86121d7931
CI / lint (pull_request) Successful in 12s
CI / typecheck (pull_request) Successful in 10s
CI / test (pull_request) Successful in 17s
CI / build (pull_request) Successful in 11s
CI / package (pull_request) Successful in 13s
CI / integration (pull_request) Successful in 7m19s
ci: separate manual release publishing
2026-10-10 22:38:36 -05:00

101 lines
7.1 KiB
Bash
Executable File

#!/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
}