feat: Gitea CI, and releases built once, previewed and promoted as one file
ci.yml runs tools/check.sh and the container gate on every push and pull request, with no secrets. Release 1 builds a version from its release commit on master or release/*, gates it, and keeps it in a Gitea pre-release. Release 2 gates that file again on the CI runner and publishes it to canary (Preview) from the release runner. Release 3 promotes the file Preview ran, on a person's approval. Import records a release from before Gitea kept the record. All of it is tools/release-ci.sh calling the existing scripts. docs/releasing.md: tracks, branches, each step, immutability, rollback, secrets, runners, Nightly and the hardware lab as designs, the Gitea administration still to do, and what was measured to make it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
583f9b1809
commit
22f25854aa
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
# The Gitea workflows keep the rules docs/releasing.md makes of them.
|
||||
#
|
||||
# Read as text, job by job, because the runner that runs this has no YAML
|
||||
# library and must not need one. The rules are the ones that matter if a
|
||||
# workflow is edited carelessly later: what a pull request can reach,
|
||||
# what publishes and when, and that the work stays in the scripts.
|
||||
# Whether Gitea itself parses and plans them was measured with act_runner
|
||||
# (docs/releasing.md, "What was measured").
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
python3 - "$ROOT" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
flows = root / ".gitea/workflows"
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" ok {label}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {label}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
def read(name):
|
||||
return (flows / name).read_text()
|
||||
|
||||
|
||||
def triggers(text):
|
||||
block = re.search(r"^on:\n((?: .*\n|\n)+)", text, re.M)
|
||||
return set(re.findall(r"^ ([a-z_]+):", block.group(1), re.M)) if block else set()
|
||||
|
||||
|
||||
def jobs(text):
|
||||
"""{job id: its text}, from the jobs: block."""
|
||||
body = text[text.index("\njobs:\n") + 7:]
|
||||
found = {}
|
||||
for match in re.finditer(r"^ ([a-z_]+):\n((?: .*\n|\n)*)", body, re.M):
|
||||
found[match.group(1)] = match.group(2)
|
||||
return found
|
||||
|
||||
|
||||
def runs_on(job):
|
||||
match = re.search(r"^ runs-on: (\S+)$", job, re.M)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
names = sorted(p.name for p in flows.glob("*.yml"))
|
||||
check("the workflows are the ones documented", names == [
|
||||
"ci.yml", "release-build.yml", "release-import.yml",
|
||||
"release-preview.yml", "release-stable.yml"], str(names))
|
||||
|
||||
releasing = (root / "docs/releasing.md").read_text()
|
||||
|
||||
print("1. CI: every change, no secrets, nothing kept that could be installed")
|
||||
ci = read("ci.yml")
|
||||
check("it runs on push and on pull requests", triggers(ci) == {"push", "pull_request"}, str(triggers(ci)))
|
||||
check("it reads no secret and no token", "secrets." not in ci and "github.token" not in ci)
|
||||
check("every job runs on the CI runner",
|
||||
{runs_on(job) for job in jobs(ci).values()} == {"ekklesia-ci"})
|
||||
check("the checks are tools/check.sh, the same list a release build runs",
|
||||
"tools/check.sh --base" in ci
|
||||
and '"$ROOT/tools/check.sh"' in (root / "tools/build-release.sh").read_text())
|
||||
check("the container gate is the release gate, in its CI mode", "tools/release-gate.sh --ci" in ci)
|
||||
check("the package is inspected, not kept",
|
||||
"dist/" not in re.sub(r"(?s)^.*?Keep the package's contents", "", ci)
|
||||
.split("path:", 1)[-1])
|
||||
check("building proves the version and changes nothing else in the tree",
|
||||
'dpkg-deb --field "$deb" Version)" = "$version"' in ci
|
||||
and "git status --porcelain --untracked-files=no" in ci)
|
||||
|
||||
print("2. releases: only by hand, and only through the scripts")
|
||||
release_flows = [n for n in names if n.startswith("release-")]
|
||||
for name in release_flows:
|
||||
text = read(name)
|
||||
check(f"{name} only runs when a person runs it", triggers(text) == {"workflow_dispatch"},
|
||||
str(triggers(text)))
|
||||
# An input pasted into a script is a script anyone who can run the
|
||||
# workflow can write. Inputs reach the shell as environment only.
|
||||
runs = "\n".join(re.findall(r"run: \|?\n?((?: .*\n)+|.*\n)", text))
|
||||
check(f"{name} never pastes an input into a command",
|
||||
"${{" not in runs, re.findall(r"\$\{\{[^}]*\}\}", runs)[:3])
|
||||
check(f"{name}'s work is tools/release-ci.sh", "tools/release-ci.sh " in text)
|
||||
|
||||
print("3. who holds what")
|
||||
where = {}
|
||||
for name in names:
|
||||
for job_id, job in jobs(read(name)).items():
|
||||
where[f"{name}:{job_id}"] = (runs_on(job), job)
|
||||
|
||||
key_jobs = {k for k, (_, job) in where.items() if "CLASSROOM_UPDATES_SSH_KEY" in job}
|
||||
check("the update server's key is given to exactly Preview's publish and Stable's promote",
|
||||
key_jobs == {"release-preview.yml:publish", "release-stable.yml:promote"}, str(key_jobs))
|
||||
check("and those run only on the release runner",
|
||||
all(where[k][0] == "ekklesia-release" for k in key_jobs))
|
||||
check("nothing on the release runner builds or gates (it has no Docker)",
|
||||
all("release-ci.sh build" not in job and "release-ci.sh gate" not in job
|
||||
and "release-gate.sh" not in job
|
||||
for runner, job in where.values() if runner == "ekklesia-release"))
|
||||
check("nothing on the CI runner is given the key",
|
||||
all("CLASSROOM_UPDATES_SSH_KEY" not in job
|
||||
for runner, job in where.values() if runner == "ekklesia-ci"))
|
||||
check("the key is a secret; where it goes is a variable",
|
||||
all("secrets.CLASSROOM_UPDATES_SSH_KEY" in where[k][1]
|
||||
and "vars.CLASSROOM_UPDATES_KNOWN_HOSTS" in where[k][1] for k in key_jobs))
|
||||
|
||||
print("4. Preview and Stable")
|
||||
preview = jobs(read("release-preview.yml"))
|
||||
check("Preview gates on the CI runner, then publishes on the release runner",
|
||||
runs_on(preview["gate"]) == "ekklesia-ci" and runs_on(preview["publish"]) == "ekklesia-release"
|
||||
and "needs: gate" in preview["publish"])
|
||||
check("the publish job relies on that gate's report, by name",
|
||||
"needs.gate.outputs.report" in preview["publish"])
|
||||
check("Preview asks for the sha256 of the file", "sha256:" in read("release-preview.yml"))
|
||||
stable = read("release-stable.yml")
|
||||
check("Stable asks for the sha256, an approval and a typed confirmation",
|
||||
all(f" {field}:" in stable for field in ("sha256", "approval", "confirm"))
|
||||
and 'PROMOTE $VERSION TO STABLE' in stable)
|
||||
check("Stable never builds or gates", "build" not in re.sub(r"#.*", "", stable).replace("rebuild", "")
|
||||
and "release-gate" not in stable)
|
||||
|
||||
print("5. what the measurements found")
|
||||
check("artifacts are uploaded with v3, which Gitea 1.27 takes (v4 is refused)",
|
||||
all("upload-artifact@v4" not in read(n) for n in names)
|
||||
and "upload-artifact@v3" in ci)
|
||||
check("the release token falls back to Gitea's own, which was measured to work",
|
||||
all("secrets.RELEASE_API_TOKEN || github.token" in read(n) for n in release_flows))
|
||||
check("the docs say labels in a runner's config file win over --labels",
|
||||
"OVERRIDE --labels" in releasing)
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
Reference in New Issue
Block a user