diff --git a/docs/releasing.md b/docs/releasing.md index 82aaf9f..c59c845 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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. diff --git a/tests/test-release-ci.sh b/tests/test-release-ci.sh index cc73217..5e83c06 100755 --- a/tests/test-release-ci.sh +++ b/tests/test-release-ci.sh @@ -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'" diff --git a/tests/test-release-versions.sh b/tests/test-release-versions.sh index 1923c40..c0ee5c4 100755 --- a/tests/test-release-versions.sh +++ b/tests/test-release-versions.sh @@ -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 diff --git a/tools/release-ci.sh b/tools/release-ci.sh index 6c0ab24..bd95aee 100755 --- a/tools/release-ci.sh +++ b/tools/release-ci.sh @@ -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" < "$dir/key") + trap 'rm -f "$WORK/ssh/key"' EXIT + printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$dir/known_hosts" + cat > "$dir/config" <