fix: a release job uses only the key it is given, never the runner's own
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:
co-authored by
Claude Opus 5.5
parent
22f25854aa
commit
14291fde37
+15
-2
@@ -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
|
||||
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,
|
||||
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`,
|
||||
@@ -495,7 +502,13 @@ runs), on the development VM, 2026-09-29:
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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 |
|
||||
|
||||
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
|
||||
stand-in server and a stand-in Gitea.
|
||||
|
||||
@@ -85,6 +85,11 @@ point canary 0.2.6.5
|
||||
|
||||
cat > "$WORK/bin/ssh" <<'EOF'
|
||||
#!/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
|
||||
exec bash -c "$*"
|
||||
EOF
|
||||
@@ -95,7 +100,7 @@ args=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ignore-existing) keep=1 ;;
|
||||
-e) shift ;;
|
||||
-e) echo "rsync $2" >> "${SSH_LOG:-/dev/null}"; shift ;;
|
||||
-*) ;;
|
||||
*) args+=("$1") ;;
|
||||
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" \
|
||||
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" \
|
||||
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'"
|
||||
|
||||
@@ -86,6 +86,11 @@ cp "$SERVER/canary/manifest.json" "$LOCAL/canary/"
|
||||
# to one host, and it is this directory.
|
||||
cat > "$WORK/bin/ssh" <<'EOF'
|
||||
#!/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
|
||||
exec bash -c "$*"
|
||||
EOF
|
||||
@@ -98,7 +103,7 @@ args=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ignore-existing) keep=1 ;;
|
||||
-e) shift ;;
|
||||
-e) echo "rsync $2" >> "${SSH_LOG:-/dev/null}"; shift ;;
|
||||
-*) ;;
|
||||
*) args+=("$1") ;;
|
||||
esac
|
||||
|
||||
+28
-11
@@ -30,7 +30,8 @@
|
||||
# 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.
|
||||
# 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
|
||||
shopt -s inherit_errexit
|
||||
|
||||
@@ -109,30 +110,46 @@ fetch() {
|
||||
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.
|
||||
# The update server, for the release runner: the key it was given, in a
|
||||
# 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() {
|
||||
local dir="$WORK/ssh"
|
||||
|
||||
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
|
||||
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
|
||||
mkdir -p "$dir"
|
||||
chmod 700 "$dir"
|
||||
(umask 077; printf '%s\n' "$RELEASE_SSH_KEY" > "$dir/key")
|
||||
trap 'rm -f "$WORK/ssh/key"' EXIT
|
||||
printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$dir/known_hosts"
|
||||
cat > "$dir/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
|
||||
IdentityFile $dir/key
|
||||
IdentitiesOnly yes
|
||||
UserKnownHostsFile $dir/known_hosts
|
||||
GlobalKnownHostsFile /dev/null
|
||||
StrictHostKeyChecking yes
|
||||
BatchMode yes
|
||||
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
|
||||
|
||||
+21
-3
@@ -66,8 +66,26 @@ release_package_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() {
|
||||
ssh "$RELEASE_HOST" "$@"
|
||||
release_ssh "$RELEASE_HOST" "$@"
|
||||
}
|
||||
|
||||
# Every version that has ever been published, one per line, read from the
|
||||
@@ -151,7 +169,7 @@ release_upload_package() {
|
||||
name="$(basename "$package")"
|
||||
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/"
|
||||
|
||||
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
|
||||
@@ -192,7 +210,7 @@ release_publish_manifest() {
|
||||
EOF
|
||||
|
||||
# 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_HOST:$RELEASE_ROOT/$channel/manifest.json"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user