fix: a release job uses only the key it is given, never the runner's own
CI / checks (push) Failing after 2m13s
CI / package and container gate (push) Skipped

Measured on a throwaway Gitea: with the key secret unset, Preview's
publish job would have fallen back to the runner machine's own ssh
configuration — on the development VM, the production publish key — and
published a throwaway 9.9.9 to the real server. It was cancelled before
it ran. And with the key set, the job wrote over the runner account's own
~/.ssh/config and known_hosts.

Now, under Actions, a job without RELEASE_SSH_KEY stops; with it, the key,
its known_hosts and an ssh configuration live in the job's own directory
and every ssh and rsync goes through `ssh -F` (RELEASE_SSH_CONFIG in
release-lib), which reads nothing else. The key is deleted when the job
ends. By hand, nothing changes. Also records the end-to-end measurements
of Release 1 and of Preview's gate job in docs/releasing.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-29 20:24:50 -05:00
co-authored by Claude Opus 5.5
parent 22f25854aa
commit 14291fde37
5 changed files with 103 additions and 19 deletions
+15 -2
View File
@@ -362,6 +362,13 @@ Gitea 1.27.2 with act_runner 0.6.1:
**Anyone with write access can publish**, by writing a workflow that uses the **Anyone with write access can publish**, by writing a workflow that uses the
key. Give write access accordingly. key. Give write access accordingly.
The release scripts never use a runner's own ssh keys: under Actions the
key comes only from the secret, is written to the job's own directory with
its own `known_hosts` and an ssh configuration used with `ssh -F` (which
ignores `~/.ssh/config`), and is deleted when the job ends. A job without
the secret stops. **Never put a runner on a machine that holds the publish
key by hand** — the development VM does — in case that rule is ever broken.
So: the key lives only in Gitea's secret store, never on a runner's disk, So: the key lives only in Gitea's secret store, never on a runner's disk,
where an approved fork job could read it. The `ekklesia-release` runner runs where an approved fork job could read it. The `ekklesia-release` runner runs
each job in a fresh container (`ekklesia-release:docker://node:22-trixie`, each job in a fresh container (`ekklesia-release:docker://node:22-trixie`,
@@ -495,7 +502,13 @@ runs), on the development VM, 2026-09-29:
| Question | Answer | | Question | Answer |
| --- | --- | | --- | --- |
| Do all five workflows parse, and plan only on their own events? | Yes: CI on push and pull_request; the release workflows only on a manual run | | Do all five workflows parse, and plan only on their own events? | Yes: CI on push and pull_request; the release workflows only on a manual run |
| Does the CI checks job pass on a real runner? | Yes: 30 of 30, and its logs upload as an artifact | | Does the CI checks job pass on a real runner? | Yes: all 31 checks, and their logs upload as an artifact |
| Does CI's package job build, and run the gate in its CI mode? | Yes: built, the tree unchanged, fresh install and both upgrades passed, the report marked `mode ci` |
| Does **Release 1 - build** work end to end? | Yes, at a throwaway version 9.9.9 against what canary (0.2.6.6) and stable (0.2.6.4) really serve: built from the commit with the tree unchanged, full gate passed, pre-release `v9.9.9` tagged at exactly that commit with the package, SHA256SUMS, build-info.json, gate report and check summary |
| Is a second build of the same version refused? | Yes: "v9.9.9 already has a Gitea release; a version is built once" |
| Does Preview's gate job work? | Yes: it downloaded the release's file, proved it against the sha256 given and SHA256SUMS, passed the full gate, attached a uniquely named report, recorded it, and handed its name to the publish job |
| If the key secret is missing, what would the publish job use? | **This machine's own ssh configuration** — on the development VM, the production publish key: a throwaway 9.9.9 would have gone to the real server. The job was cancelled before it ran, and since then a job under Actions without the key refuses, and one with it uses only its own configuration (`ssh -F`), never the runner's |
| Does a failing check stop a release build? | Yes: an undocumented workflow left in the tree failed `test-workflows`, and nothing was built |
| Can the automatic token create a release, attach an asset, edit the notes? | Yes | | Can the automatic token create a release, attach an asset, edit the notes? | Yes |
| Does `tools/gitea-release` refuse different bytes under a published name, on real Gitea? | Yes | | Does `tools/gitea-release` refuse different bytes under a published name, on real Gitea? | Yes |
| Do `github.event.inputs.X` and `inputs.X` both work? | Yes | | Do `github.event.inputs.X` and `inputs.X` both work? | Yes |
@@ -507,6 +520,6 @@ runs), on the development VM, 2026-09-29:
| Approved, does it get secrets? | No. It gets variables, a token, and any runner it names | | Approved, does it get secrets? | No. It gets variables, a token, and any runner it names |
Not measured, because it needs the real update server's key in a real Not measured, because it needs the real update server's key in a real
runner: the publish and promote jobs end to end. Their scripts are the ones runner: Preview's publish job and Stable's promote job end to end. Their scripts are the ones
used by hand since 0.2.6, and `tests/test-release-ci.sh` runs them against a used by hand since 0.2.6, and `tests/test-release-ci.sh` runs them against a
stand-in server and a stand-in Gitea. stand-in server and a stand-in Gitea.
+33 -2
View File
@@ -85,6 +85,11 @@ point canary 0.2.6.5
cat > "$WORK/bin/ssh" <<'EOF' cat > "$WORK/bin/ssh" <<'EOF'
#!/bin/bash #!/bin/bash
# ssh [-F CONFIG] HOST COMMAND: run COMMAND here, and note which
# configuration it was given ("-" for the machine's own).
config=-
if [[ "$1" == -F ]]; then config="$2"; shift 2; fi
echo "ssh $config" >> "${SSH_LOG:-/dev/null}"
shift shift
exec bash -c "$*" exec bash -c "$*"
EOF EOF
@@ -95,7 +100,7 @@ args=()
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--ignore-existing) keep=1 ;; --ignore-existing) keep=1 ;;
-e) shift ;; -e) echo "rsync $2" >> "${SSH_LOG:-/dev/null}"; shift ;;
-*) ;; -*) ;;
*) args+=("$1") ;; *) args+=("$1") ;;
esac esac
@@ -266,7 +271,33 @@ check "a Gitea that cannot be asked is never taken as having no release" \
refuses_saying "could not ask Gitea whether v0.2.6.9 exists" \ refuses_saying "could not ask Gitea whether v0.2.6.9 exists" \
env GITEA_URL=http://127.0.0.1:9 bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' import 0.2.6.9 '$H5' '$COMMIT'" env GITEA_URL=http://127.0.0.1:9 bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' import 0.2.6.9 '$H5' '$COMMIT'"
echo "5. build: only from master or a maintenance branch" echo "5. the release runner uses the key it is given, and only that"
H8="$(make_release 0.2.6.8)"
report "$WORK/reports/gate-report-preview-0.2.6.8.txt" 0.2.6.8 "$H8" "$(serves canary)" "$(serves stable)"
"$GITEA" upload v0.2.6.8 "$WORK/reports/gate-report-preview-0.2.6.8.txt" >/dev/null
before="$(packages)"
check "under Actions, a job with no key stops before anything is published" \
refuses_saying "A job never uses the runner's own ssh keys" \
env GITHUB_ACTIONS=true bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' preview 0.2.6.8 '$H8' gate-report-preview-0.2.6.8.txt"
check "and nothing was" test "$(packages)" = "$before"
job="$(mktemp -d -p "$WORK")"
export SSH_LOG="$WORK/ssh.log"
: > "$SSH_LOG"
check "with a key, it publishes" \
env GITHUB_ACTIONS=true RELEASE_CI_WORK="$job" RELEASE_SSH_KEY="not a real key" \
RELEASE_SSH_KNOWN_HOSTS="10.0.0.1 ssh-ed25519 AAAA" RELEASE_SSH_ADDRESS=10.0.0.1 \
RELEASE_SSH_USER=classroom-publish "$CI" preview 0.2.6.8 "$H8" gate-report-preview-0.2.6.8.txt
check "every ssh and rsync it made used the job's own configuration" \
bash -c "[[ -s '$SSH_LOG' ]] && ! grep -v -e 'ssh $job/ssh/config' -e 'rsync ssh -F $job/ssh/config' '$SSH_LOG'"
check "which names the key, the host key and the account it was given" \
bash -c "grep -qx ' IdentityFile $job/ssh/key' '$job/ssh/config' \
&& grep -qx ' UserKnownHostsFile $job/ssh/known_hosts' '$job/ssh/config' \
&& grep -qx ' User classroom-publish' '$job/ssh/config' \
&& grep -qx ' StrictHostKeyChecking yes' '$job/ssh/config'"
check "and the key is gone when the job ends" test ! -e "$job/ssh/key"
unset SSH_LOG
echo "6. build: only from master or a maintenance branch"
check "a feature branch is refused before anything is built" \ check "a feature branch is refused before anything is built" \
bash -c "GITHUB_REF=refs/heads/feat/something RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' build 0.2.6.8 2>&1 \ bash -c "GITHUB_REF=refs/heads/feat/something RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' build 0.2.6.8 2>&1 \
| grep -q 'releases are built from master or release/\\*, not refs/heads/feat/something'" | grep -q 'releases are built from master or release/\\*, not refs/heads/feat/something'"
+6 -1
View File
@@ -86,6 +86,11 @@ cp "$SERVER/canary/manifest.json" "$LOCAL/canary/"
# to one host, and it is this directory. # to one host, and it is this directory.
cat > "$WORK/bin/ssh" <<'EOF' cat > "$WORK/bin/ssh" <<'EOF'
#!/bin/bash #!/bin/bash
# ssh [-F CONFIG] HOST COMMAND: run COMMAND here, and note which
# configuration it was given ("-" for the machine's own).
config=-
if [[ "$1" == -F ]]; then config="$2"; shift 2; fi
echo "ssh $config" >> "${SSH_LOG:-/dev/null}"
shift shift
exec bash -c "$*" exec bash -c "$*"
EOF EOF
@@ -98,7 +103,7 @@ args=()
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--ignore-existing) keep=1 ;; --ignore-existing) keep=1 ;;
-e) shift ;; -e) echo "rsync $2" >> "${SSH_LOG:-/dev/null}"; shift ;;
-*) ;; -*) ;;
*) args+=("$1") ;; *) args+=("$1") ;;
esac esac
+28 -11
View File
@@ -30,7 +30,8 @@
# release. preview and stable also need the update server, through # release. preview and stable also need the update server, through
# RELEASE_SSH_KEY (the private key's text), RELEASE_SSH_KNOWN_HOSTS, # 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 # 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. # jobs are given, and without which a job refuses to publish. Run by hand
# (no GITHUB_ACTIONS), ~/.ssh/config is used as it always was.
set -euo pipefail set -euo pipefail
shopt -s inherit_errexit shopt -s inherit_errexit
@@ -109,30 +110,46 @@ fetch() {
printf '%s\n' "$WORK/$name" printf '%s\n' "$WORK/$name"
} }
# The update server, for the release runner. The key is written to a file # The update server, for the release runner: the key it was given, in a
# only this job can read, and is gone when the job's container is. # configuration of its own (RELEASE_SSH_CONFIG, used with ssh -F), so
# nothing on the runner's own disk is read or overwritten, and the key is
# deleted when this ends. Under Actions there is no other way: a job
# without the key stops, rather than reach for whatever credentials the
# machine it runs on happens to hold. Measured: on the development VM,
# which holds the production publish key, the fallback would have
# published a throwaway build to the real server.
ssh_setup() { ssh_setup() {
local dir="$WORK/ssh"
if [[ -z "${RELEASE_SSH_KEY:-}" ]]; then if [[ -z "${RELEASE_SSH_KEY:-}" ]]; then
echo "release-ci: no RELEASE_SSH_KEY; using this machine's own ssh configuration" >&2 if [[ -n "${GITHUB_ACTIONS:-}${GITEA_ACTIONS:-}" ]]; then
fail "no RELEASE_SSH_KEY: set the CLASSROOM_UPDATES_SSH_KEY secret. A job never uses the runner's own ssh keys"
fi
echo "release-ci: no RELEASE_SSH_KEY; by hand, so this machine's own ssh configuration" >&2
return return
fi fi
[[ -n "${RELEASE_SSH_KNOWN_HOSTS:-}" ]] || fail "RELEASE_SSH_KNOWN_HOSTS is required with RELEASE_SSH_KEY" [[ -n "${RELEASE_SSH_KNOWN_HOSTS:-}" ]] || fail "RELEASE_SSH_KNOWN_HOSTS is required with RELEASE_SSH_KEY"
mkdir -p "$HOME/.ssh" mkdir -p "$dir"
chmod 700 "$HOME/.ssh" chmod 700 "$dir"
(umask 077; printf '%s\n' "$RELEASE_SSH_KEY" > "$HOME/.ssh/release-key") (umask 077; printf '%s\n' "$RELEASE_SSH_KEY" > "$dir/key")
printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" trap 'rm -f "$WORK/ssh/key"' EXIT
cat > "$HOME/.ssh/config" <<EOF printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$dir/known_hosts"
cat > "$dir/config" <<EOF
Host $RELEASE_HOST Host $RELEASE_HOST
HostName ${RELEASE_SSH_ADDRESS:?RELEASE_SSH_ADDRESS is required} HostName ${RELEASE_SSH_ADDRESS:?RELEASE_SSH_ADDRESS is required}
User ${RELEASE_SSH_USER:?RELEASE_SSH_USER is required} User ${RELEASE_SSH_USER:?RELEASE_SSH_USER is required}
IdentityFile $HOME/.ssh/release-key IdentityFile $dir/key
IdentitiesOnly yes IdentitiesOnly yes
UserKnownHostsFile $dir/known_hosts
GlobalKnownHostsFile /dev/null
StrictHostKeyChecking yes StrictHostKeyChecking yes
BatchMode yes BatchMode yes
EOF EOF
chmod 600 "$HOME/.ssh/config" chmod 600 "$dir/config"
export RELEASE_SSH_CONFIG="$dir/config"
} }
# Every package on the server and its checksum, to prove afterwards that # Every package on the server and its checksum, to prove afterwards that
+21 -3
View File
@@ -66,8 +66,26 @@ release_package_version() {
dpkg-deb --field "$1" Version dpkg-deb --field "$1" Version
} }
# ssh to the update server. With RELEASE_SSH_CONFIG, that file and nothing
# else configures it — `ssh -F` ignores ~/.ssh/config and the system's — so
# a release job uses only the key it was given, and never the credentials
# of whatever machine it happens to run on. Without it, this machine's own
# configuration, as a person publishing by hand always has.
release_ssh() {
if [[ -n "${RELEASE_SSH_CONFIG:-}" ]]; then
ssh -F "$RELEASE_SSH_CONFIG" "$@"
else
ssh "$@"
fi
}
# The same, as rsync's -e.
release_rsync_ssh() {
printf 'ssh%s\n' "${RELEASE_SSH_CONFIG:+ -F $RELEASE_SSH_CONFIG}"
}
release_remote() { release_remote() {
ssh "$RELEASE_HOST" "$@" release_ssh "$RELEASE_HOST" "$@"
} }
# Every version that has ever been published, one per line, read from the # Every version that has ever been published, one per line, read from the
@@ -151,7 +169,7 @@ release_upload_package() {
name="$(basename "$package")" name="$(basename "$package")"
local_hash="$(sha256sum "$package" | awk '{print $1}')" local_hash="$(sha256sum "$package" | awk '{print $1}')"
rsync -av --ignore-existing -e ssh \ rsync -av --ignore-existing -e "$(release_rsync_ssh)" \
"$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/" "$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/"
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')" remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
@@ -192,7 +210,7 @@ release_publish_manifest() {
EOF EOF
# Manifest deliberately goes LAST, after the package it names. # Manifest deliberately goes LAST, after the package it names.
rsync -av -e ssh \ rsync -av -e "$(release_rsync_ssh)" \
"$RELEASE_LOCAL/$channel/manifest.json" \ "$RELEASE_LOCAL/$channel/manifest.json" \
"$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json" "$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json"