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]>
385 lines
16 KiB
Bash
Executable File
385 lines
16 KiB
Bash
Executable File
#!/bin/bash
|
|
# THE RELEASE STEPS, AS THE GITEA WORKFLOWS TAKE THEM — OR A PERSON DOES.
|
|
#
|
|
# tools/release-ci.sh build VERSION [FROM_VERSION...]
|
|
# tools/release-ci.sh gate VERSION SHA256
|
|
# tools/release-ci.sh preview VERSION SHA256 GATE_REPORT_NAME
|
|
# tools/release-ci.sh stable VERSION SHA256 APPROVAL
|
|
# tools/release-ci.sh import VERSION SHA256 COMMIT [GATE_REPORT_FILE]
|
|
#
|
|
# Glue, and only glue. Building is tools/build-release.sh, the gate is
|
|
# tools/release-gate.sh, publishing is update_canary.sh and promoting is
|
|
# update_stable.sh, exactly as when a person runs them; the record is
|
|
# tools/gitea-release. Nothing here decides what a good release is, so
|
|
# nothing here can come to disagree with them. See docs/releasing.md.
|
|
#
|
|
# build from the commit checked out, whose VERSION says VERSION: the
|
|
# package, its SHA256SUMS and build-info.json, the gate, and a
|
|
# Gitea pre-release vVERSION holding all of it. Refuses a
|
|
# version that has a release, a tag or a package already.
|
|
# gate the gate again, on the file that release holds, for Preview;
|
|
# the report is attached to the release under a new name.
|
|
# preview that file, published to canary (Preview) by update_canary.sh
|
|
# on that gate report, read back, and recorded.
|
|
# stable that file, promoted by update_stable.sh VERSION SHA256 once a
|
|
# person has approved it, read back, and recorded.
|
|
# import a release published before Gitea kept the record (0.2.6.6):
|
|
# the file as the update server serves it, recorded as-is.
|
|
#
|
|
# CREDENTIALS. build, gate and import need only GITEA_TOKEN, for the
|
|
# release. preview and stable also need the update server, through
|
|
# RELEASE_SSH_KEY (the private key's text), RELEASE_SSH_KNOWN_HOSTS,
|
|
# RELEASE_SSH_ADDRESS and RELEASE_SSH_USER, which only the release runner's
|
|
# jobs are given. Run by hand, ~/.ssh/config is used as it always was.
|
|
set -euo pipefail
|
|
shopt -s inherit_errexit
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$ROOT"
|
|
source "$ROOT/tools/release-lib.sh"
|
|
|
|
GITEA="$ROOT/tools/gitea-release"
|
|
WORK="${RELEASE_CI_WORK:-$(mktemp -d)}"
|
|
mkdir -p "$WORK"
|
|
|
|
run_url() {
|
|
if [[ -n "${GITHUB_RUN_NUMBER:-}" ]]; then
|
|
printf '%s/%s/actions/runs/%s\n' "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_NUMBER"
|
|
else
|
|
printf 'by hand on %s by %s\n' "$(hostname)" "$(id -un)"
|
|
fi
|
|
}
|
|
|
|
actor() { printf '%s\n' "${GITHUB_ACTOR:-$(id -un)}"; }
|
|
|
|
fail() { echo "release-ci: $*" >&2; exit 1; }
|
|
|
|
# The version a channel serves, read the way a classroom reads it.
|
|
served() {
|
|
curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$1/manifest.json" \
|
|
| python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])'
|
|
}
|
|
|
|
# Does Gitea have a release vVERSION? Yes, no, or stop: a question Gitea
|
|
# could not answer is never taken as "no".
|
|
has_release() {
|
|
local status=0
|
|
"$GITEA" show "v$1" >/dev/null 2>&1 || status=$?
|
|
|
|
case "$status" in
|
|
0) return 0 ;;
|
|
3) return 1 ;;
|
|
*) fail "could not ask Gitea whether v$1 exists" ;;
|
|
esac
|
|
}
|
|
|
|
# Is a package of this name on the update server? Only a 404 is a no.
|
|
on_server() {
|
|
local code
|
|
code="$(curl -sS -o /dev/null -w '%{http_code}' -I "$RELEASE_URL/packages/$1" || true)"
|
|
|
|
case "$code" in
|
|
200) return 0 ;;
|
|
404) return 1 ;;
|
|
*) fail "could not ask the update server about $1 (HTTP ${code:-none})" ;;
|
|
esac
|
|
}
|
|
|
|
require_sha256() {
|
|
[[ "$1" =~ ^[0-9a-f]{64}$ ]] || fail "not a SHA-256 checksum: $1"
|
|
}
|
|
|
|
# The release's package, downloaded and proven to be the file named: the
|
|
# checksum given, the checksum the release's own SHA256SUMS records, and
|
|
# the version dpkg reads out of it all have to agree.
|
|
fetch() {
|
|
local version="$1" hash="$2" name
|
|
name="$(release_package_name "$version")"
|
|
|
|
"$GITEA" download "v$version" "$name" "$WORK/$name" >&2
|
|
"$GITEA" download "v$version" SHA256SUMS "$WORK/SHA256SUMS" >&2
|
|
|
|
[[ "$(sha256sum "$WORK/$name" | awk '{print $1}')" == "$hash" ]] \
|
|
|| fail "v$version's $name is not sha256 $hash"
|
|
grep -qx "$hash $name" "$WORK/SHA256SUMS" \
|
|
|| fail "v$version's SHA256SUMS does not record $name as $hash"
|
|
[[ "$(release_package_version "$WORK/$name")" == "$version" ]] \
|
|
|| fail "$name does not say it is $version"
|
|
|
|
printf '%s\n' "$WORK/$name"
|
|
}
|
|
|
|
# The update server, for the release runner. The key is written to a file
|
|
# only this job can read, and is gone when the job's container is.
|
|
ssh_setup() {
|
|
if [[ -z "${RELEASE_SSH_KEY:-}" ]]; then
|
|
echo "release-ci: no RELEASE_SSH_KEY; using this machine's own ssh configuration" >&2
|
|
return
|
|
fi
|
|
|
|
[[ -n "${RELEASE_SSH_KNOWN_HOSTS:-}" ]] || fail "RELEASE_SSH_KNOWN_HOSTS is required with RELEASE_SSH_KEY"
|
|
|
|
mkdir -p "$HOME/.ssh"
|
|
chmod 700 "$HOME/.ssh"
|
|
(umask 077; printf '%s\n' "$RELEASE_SSH_KEY" > "$HOME/.ssh/release-key")
|
|
printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
|
cat > "$HOME/.ssh/config" <<EOF
|
|
Host $RELEASE_HOST
|
|
HostName ${RELEASE_SSH_ADDRESS:?RELEASE_SSH_ADDRESS is required}
|
|
User ${RELEASE_SSH_USER:?RELEASE_SSH_USER is required}
|
|
IdentityFile $HOME/.ssh/release-key
|
|
IdentitiesOnly yes
|
|
StrictHostKeyChecking yes
|
|
BatchMode yes
|
|
EOF
|
|
chmod 600 "$HOME/.ssh/config"
|
|
}
|
|
|
|
# Every package on the server and its checksum, to prove afterwards that
|
|
# publishing added one file and changed none.
|
|
server_packages() {
|
|
release_remote "cd '$RELEASE_ROOT/packages' && sha256sum -- *.deb" | sort
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
cmd_build() {
|
|
local version="$1" commit name out package hash previous
|
|
shift
|
|
release_require_version "$version"
|
|
|
|
# Releases are cut from the integration branch or a maintenance branch,
|
|
# never from a feature branch that nobody has reviewed.
|
|
local ref="${GITHUB_REF:-refs/heads/$(git rev-parse --abbrev-ref HEAD)}"
|
|
case "$ref" in
|
|
refs/heads/master|refs/heads/release/*) ;;
|
|
*) fail "releases are built from master or release/*, not $ref" ;;
|
|
esac
|
|
|
|
[[ -z "$(git status --porcelain --untracked-files=no)" ]] || fail "the checkout has uncommitted changes"
|
|
[[ "$(cat VERSION)" == "$version" ]] \
|
|
|| fail "VERSION says $(cat VERSION). Commit the release first: VERSION=$version, the manual rebuilt"
|
|
|
|
commit="$(git rev-parse HEAD)"
|
|
name="$(release_package_name "$version")"
|
|
|
|
# NEVER A VERSION TWICE. Not one Gitea has, not one the update server
|
|
# has, and not one that is not newer than what either channel serves.
|
|
# update_canary.sh asks the server the authoritative question again,
|
|
# over ssh, before anything is published.
|
|
if has_release "$version"; then
|
|
fail "v$version already has a Gitea release; a version is built once"
|
|
fi
|
|
|
|
if on_server "$name"; then
|
|
fail "$name is already on the update server"
|
|
fi
|
|
|
|
for channel in canary stable; do
|
|
release_version_gt "$version" "$(served "$channel")" \
|
|
|| fail "$channel serves $(served "$channel"); $version is not newer"
|
|
done
|
|
|
|
# The manual's revision date is the commit's, so the build reproduces
|
|
# the tree it was built from rather than stamping today on it.
|
|
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" CHECK_LOG_DIR="$WORK/checks" \
|
|
"$ROOT/tools/build-release.sh" "$version"
|
|
|
|
[[ -z "$(git status --porcelain --untracked-files=no)" ]] \
|
|
|| { git status --short >&2; fail "the build changed the tree; the package is not the commit's"; }
|
|
|
|
out="$ROOT/dist/release-$version"
|
|
rm -rf "$out"
|
|
mkdir -p "$out"
|
|
package="$out/$name"
|
|
cp "$ROOT/dist/$name" "$package"
|
|
hash="$(sha256sum "$package" | awk '{print $1}')"
|
|
(cd "$out" && sha256sum "$name" > SHA256SUMS)
|
|
|
|
python3 - "$out/build-info.json" <<PY
|
|
import json, sys
|
|
json.dump({
|
|
"version": "$version",
|
|
"package": "$name",
|
|
"sha256": "$hash",
|
|
"commit": "$commit",
|
|
"ref": "$ref",
|
|
"built": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
"built_by": "$(run_url)",
|
|
"dpkg_version": "$(release_package_version "$package")",
|
|
}, open(sys.argv[1], "w"), indent=2)
|
|
PY
|
|
cp "$WORK/checks/summary.md" "$out/checks.md"
|
|
|
|
RELEASE_GATE_REPORT="$out/gate-report.txt" "$ROOT/tools/release-gate.sh" "$package" "$@"
|
|
|
|
previous="$(git describe --tags --abbrev=0 --match 'v*' HEAD^ 2>/dev/null || true)"
|
|
{
|
|
echo "Ekklesia Classroom $version, built from \`$commit\` on \`${ref#refs/heads/}\`."
|
|
echo
|
|
echo "| | |"
|
|
echo "| --- | --- |"
|
|
echo "| Package | \`$name\` |"
|
|
echo "| SHA-256 | \`$hash\` |"
|
|
echo "| Source | \`$commit\` |"
|
|
echo "| Built | $(run_url) |"
|
|
echo
|
|
echo "Status: **built and gated. Not published.** Preview and Stable are"
|
|
echo "separate, deliberate steps; see docs/releasing.md."
|
|
echo
|
|
echo "## Changes${previous:+ since $previous}"
|
|
echo
|
|
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}HEAD" | head -n 60
|
|
echo
|
|
echo "## Release gate"
|
|
echo
|
|
echo '```'
|
|
cat "$out/gate-report.txt"
|
|
echo '```'
|
|
} > "$out/notes.md"
|
|
|
|
"$GITEA" create "v$version" "$commit" "Ekklesia Classroom $version" "$out/notes.md" --prerelease
|
|
"$GITEA" upload "v$version" "$package" "$out/SHA256SUMS" "$out/build-info.json" \
|
|
"$out/gate-report.txt" "$out/checks.md"
|
|
"$GITEA" record "v$version" "built and gated, sha256 \`$hash\` ($(run_url))"
|
|
|
|
echo
|
|
echo "Built $name"
|
|
echo " sha256 $hash"
|
|
echo " commit $commit"
|
|
echo "Next, deliberately: the Preview workflow, with version $version and this sha256."
|
|
[[ -z "${GITHUB_OUTPUT:-}" ]] || printf 'sha256=%s\n' "$hash" >> "$GITHUB_OUTPUT"
|
|
}
|
|
|
|
cmd_gate() {
|
|
local version="$1" hash="$2" package report
|
|
require_sha256 "$hash"
|
|
package="$(fetch "$version" "$hash")"
|
|
|
|
# A new name every run: a report attached to a release is never replaced.
|
|
report="$WORK/gate-report-preview-$(date -u +%Y%m%dT%H%M%SZ).txt"
|
|
RELEASE_GATE_REPORT="$report" "$ROOT/tools/release-gate.sh" "$package" ${RELEASE_GATE_FROM:-}
|
|
|
|
"$GITEA" upload "v$version" "$report"
|
|
"$GITEA" record "v$version" "gated for Preview: $(basename "$report") ($(run_url))"
|
|
[[ -z "${GITHUB_OUTPUT:-}" ]] || printf 'report=%s\n' "$(basename "$report")" >> "$GITHUB_OUTPUT"
|
|
}
|
|
|
|
cmd_preview() {
|
|
local version="$1" hash="$2" report_name="$3" package before after added
|
|
require_sha256 "$hash"
|
|
package="$(fetch "$version" "$hash")"
|
|
"$GITEA" download "v$version" "$report_name" "$WORK/$report_name"
|
|
|
|
ssh_setup
|
|
export RELEASE_LOCAL="$WORK/published"
|
|
before="$(server_packages)"
|
|
|
|
RELEASE_GATE_REPORT_PASSED="$WORK/$report_name" "$ROOT/update_canary.sh" "$version" "$package"
|
|
|
|
# One file added, and not a byte of any other changed.
|
|
after="$(server_packages)"
|
|
added="$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))"
|
|
[[ -z "$(comm -23 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))" ]] \
|
|
|| fail "a package already on the server changed while publishing"
|
|
[[ "$added" == "$hash $(basename "$package")" ]] \
|
|
|| fail "publishing added [$added], not exactly $(basename "$package")"
|
|
|
|
release_verify_channel canary "$version" | tee "$WORK/preview.txt"
|
|
echo "stable still serves $(served stable)" | tee -a "$WORK/preview.txt"
|
|
|
|
"$GITEA" record "v$version" "published to Preview (canary), sha256 \`$hash\`, on gate report $report_name; manifest, package and latest.deb read back over HTTPS; no other package changed; stable still serves $(served stable) ($(run_url), $(actor))"
|
|
}
|
|
|
|
cmd_stable() {
|
|
local version="$1" hash="$2" approval="$3" package before after body
|
|
require_sha256 "$hash"
|
|
[[ -n "$approval" ]] || fail "a promotion names who approved it and on what checks"
|
|
|
|
# Only a file that went to Preview is promoted, and only that file.
|
|
body="$("$GITEA" notes "v$version")"
|
|
grep -qF "published to Preview (canary), sha256 \`$hash\`" <<< "$body" \
|
|
|| fail "v$version has no record of $hash going to Preview; promote only what Preview ran"
|
|
|
|
package="$(fetch "$version" "$hash")"
|
|
ssh_setup
|
|
export RELEASE_LOCAL="$WORK/published"
|
|
mkdir -p "$RELEASE_LOCAL/packages"
|
|
cp "$package" "$RELEASE_LOCAL/packages/"
|
|
before="$(server_packages)"
|
|
|
|
"$ROOT/update_stable.sh" "$version" "$hash"
|
|
|
|
after="$(server_packages)"
|
|
[[ "$before" == "$after" ]] || fail "a package on the server changed while promoting"
|
|
|
|
"$GITEA" record "v$version" "promoted to Stable, sha256 \`$hash\`, the file Preview ran; read back over HTTPS. Approval: $approval ($(run_url), $(actor))"
|
|
"$GITEA" stable "v$version"
|
|
}
|
|
|
|
cmd_import() {
|
|
local version="$1" hash="$2" commit="$3" report="${4:-}" name out
|
|
require_sha256 "$hash"
|
|
name="$(release_package_name "$version")"
|
|
out="$WORK/import-$version"
|
|
mkdir -p "$out"
|
|
|
|
git cat-file -e "$commit^{commit}" 2>/dev/null || fail "$commit is not a commit here"
|
|
|
|
# Only a release with no record yet. One that has a record keeps it.
|
|
if has_release "$version"; then
|
|
fail "v$version already has a Gitea release; its record is kept as it is"
|
|
fi
|
|
|
|
curl -fsS -o "$out/$name" "$RELEASE_URL/packages/$name"
|
|
[[ "$(sha256sum "$out/$name" | awk '{print $1}')" == "$hash" ]] \
|
|
|| fail "the update server's $name is not sha256 $hash"
|
|
[[ "$(release_package_version "$out/$name")" == "$version" ]] || fail "$name does not say it is $version"
|
|
(cd "$out" && sha256sum "$name" > SHA256SUMS)
|
|
|
|
{
|
|
echo "Ekklesia Classroom $version, from \`$commit\`."
|
|
echo
|
|
echo "| | |"
|
|
echo "| --- | --- |"
|
|
echo "| Package | \`$name\` |"
|
|
echo "| SHA-256 | \`$hash\` |"
|
|
echo "| Source | \`$commit\` |"
|
|
echo
|
|
echo "Built and published before Gitea kept the release record. The"
|
|
echo "package here is the file the update server serves, byte for byte."
|
|
echo
|
|
echo "## Changes"
|
|
echo
|
|
git log -1 --format=%B "$commit"
|
|
} > "$out/notes.md"
|
|
|
|
"$GITEA" create "v$version" "$commit" "Ekklesia Classroom $version" "$out/notes.md" --prerelease
|
|
"$GITEA" upload "v$version" "$out/$name" "$out/SHA256SUMS" ${report:+"$report"}
|
|
"$GITEA" record "v$version" "imported from the update server, sha256 \`$hash\` ($(run_url))"
|
|
|
|
# Where it stands is read off the server, never assumed: the lines the
|
|
# stable step looks for are only written if the channel really serves
|
|
# this file now.
|
|
for channel in canary stable; do
|
|
if release_verify_channel "$channel" "$version" >/dev/null 2>&1 \
|
|
&& [[ "$(curl -fsS "$RELEASE_URL/$channel/manifest.json" \
|
|
| python3 -c 'import json, sys; print(json.load(sys.stdin)["sha256"])')" == "$hash" ]]; then
|
|
case "$channel" in
|
|
canary) "$GITEA" record "v$version" "published to Preview (canary), sha256 \`$hash\`, by update_canary.sh before Gitea kept the record; canary serves it, read back over HTTPS at import" ;;
|
|
stable) "$GITEA" record "v$version" "promoted to Stable, sha256 \`$hash\`, by update_stable.sh before Gitea kept the record; stable serves it, read back over HTTPS at import"
|
|
"$GITEA" stable "v$version" ;;
|
|
esac
|
|
fi
|
|
done
|
|
}
|
|
|
|
case "${1:-}" in
|
|
build) shift; [[ $# -ge 1 ]] || fail "build VERSION [FROM_VERSION...]"; cmd_build "$@" ;;
|
|
gate) [[ $# -eq 3 ]] || fail "gate VERSION SHA256"; cmd_gate "$2" "$3" ;;
|
|
preview) [[ $# -eq 4 ]] || fail "preview VERSION SHA256 GATE_REPORT_NAME"; cmd_preview "$2" "$3" "$4" ;;
|
|
stable) [[ $# -eq 4 ]] || fail "stable VERSION SHA256 APPROVAL"; cmd_stable "$2" "$3" "$4" ;;
|
|
import) [[ $# -ge 4 && $# -le 5 ]] || fail "import VERSION SHA256 COMMIT [GATE_REPORT_FILE]"; shift; cmd_import "$@" ;;
|
|
*) sed -n '2,8p' "$0" >&2; exit 2 ;;
|
|
esac
|