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
|
**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.
|
||||||
|
|||||||
@@ -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'"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user