From 39f1a1ad2455679df9d281a56eb36c849a9a5d0a Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Sat, 24 Oct 2026 12:07:05 +0800 Subject: [PATCH 1/4] Hold the Build Grant key on the runner The runner generates its own ed25519 key, sends the public half as holder in the check-in, and assembles ployzgrant1: from the Machine Cloud names. A legacy grant response still works. A cancelled job stops ployz build before the work dir holding the key is removed. --- README.md | 12 +++++----- build.sh | 16 ++++++++++++- grant.js | 44 +++++++++++++++++++++++++++++++++++ prepare.sh | 15 ++++++++---- test.sh | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 grant.js diff --git a/README.md b/README.md index 44b9bb1..7c2b9d7 100644 --- a/README.md +++ b/README.md @@ -22,25 +22,25 @@ Inputs are visible in GitHub, so none of them is secret. 1. Exports the Actions cache runtime (`crazy-max/ghaction-github-runtime`), so `ployz build` uses the GitHub Actions cache. 2. Turns on Docker's containerd image store if it is off. This restarts Docker and needs `sudo`. -3. Gets a GitHub OIDC token with audience `cloud` and checks in: `POST {cloud}/api/builds/{build}/check-in` with `Authorization: Bearer `. Cloud answers: +3. Generates the Build Grant key, an ed25519 key that never leaves the runner. Gets a GitHub OIDC token with audience `cloud` and checks in: `POST {cloud}/api/builds/{build}/check-in` with `Authorization: Bearer ` and `{"holder": "<64 hex public key>"}`. Cloud answers: ```json - {"grant": "ployzgrant1:…", "commit": "<40 hex>", "fingerprint": "<64 hex>", "ployzVersion": "", "deployment": { "snapshots": [{ "resolvedEnv": {} }] }} + {"machine": "<64 hex>", "commit": "<40 hex>", "fingerprint": "<64 hex>", "ployzVersion": "", "deployment": { "snapshots": [{ "resolvedEnv": {} }] }} ``` - The token, the grant, and every `resolvedEnv` value are masked (`::add-mask::`, per line) before anything else runs. Lines under 4 characters are not: masking `0` would star out every 0 in the log. A check-in that can't reach Cloud is retried; an HTTP error is not, and the job fails with Cloud's status and message. + The Action builds the grant from `machine` and its own key. Older Machines answer `grant` instead of `machine`. The token, the grant, and every `resolvedEnv` value are masked (`::add-mask::`, per line) before anything else runs. Lines under 4 characters are not: masking `0` would star out every 0 in the log. A check-in that can't reach Cloud is retried; an HTTP error is not, and the job fails with Cloud's status and message. 4. Installs exactly `ployzVersion` from `https://ployz.sh`: the version of the Cloud process that computed `fingerprint`, so they match even while Cloud rolls out a new version. The installer verifies the release checksum. The install is timed as the first Build Step, "Installing ployz". If it fails, the Action sends Cloud a final report with that step failed and naming the version (`installFailed`), so Cloud moves the build on to the next Builder. 5. Checks out `commit` without persisting credentials. -6. Runs `PLOYZ_BUILD_GRANT=… ployz build --deployment --commit --fingerprint --events `. The deployment file lives in `$RUNNER_TEMP` and is deleted when the job ends, pass or fail. +6. Runs `PLOYZ_BUILD_GRANT=… ployz build --deployment --commit --fingerprint --events `. The deployment file and the key live in `$RUNNER_TEMP` and are deleted when the job ends, pass or fail. A cancelled job stops `ployz build` first. 7. Reports the Build Steps while it builds, every few seconds, each time with a fresh OIDC token: `POST {cloud}/api/builds/{build}/steps` with `{"from": , "events": []}`, where `from` is the 0-based line the batch starts at. The lines are the "Installing ployz" step, then `ployz build --events`, which includes the push and its per-layer progress. Cloud files only lines it hasn't taken, so a retried batch is harmless. As soon as `ployz build` prints its result (the image is in the Machine), or when it fails, the last batch adds `"platforms": [...]`; empty means the build failed. That report settles the build, and Cloud takes no more. `ployz build` then uploads the build cache to the GitHub Actions cache; a failed upload is only a warning. Output `digest` is the manifest digest the Machine received. Cloud does not trust it: it reads the pushed digest from the Machine when it ends the grant. ## Runner needs -Linux, Docker with Buildx, rootful Docker on the runner's network (the push goes to `127.0.0.1`), and outbound access to `relay.ployz.dev`. GitHub-hosted Ubuntu runners have all of these. +Linux, Node.js, Docker with Buildx, rootful Docker on the runner's network (the push goes to `127.0.0.1`), and outbound access to `relay.ployz.dev`. GitHub-hosted Ubuntu runners have all of these. ## Develop -`./test.sh` runs both scripts against stubbed `curl`, `docker`, and `ployz`. `shellcheck *.sh` must pass. `oidc.sh` holds the OIDC token helper both scripts source. +`./test.sh` runs both scripts against stubbed `curl`, `docker`, and `ployz`. `shellcheck *.sh` must pass. `oidc.sh` holds the OIDC token helper both scripts source. `grant.js` makes the Build Grant key and assembles the grant. ## Publish diff --git a/build.sh b/build.sh index 7f94db9..f179d19 100755 --- a/build.sh +++ b/build.sh @@ -11,11 +11,24 @@ cloud=${PLOYZ_CLOUD%/} events="$work/events.jsonl" # How often, in seconds, new Build Steps go to Cloud while the build runs. interval=${PLOYZ_STEPS_INTERVAL:-5} -PLOYZ_BUILD_GRANT=$(jq -r '.grant' "$work/check-in.json") +PLOYZ_BUILD_GRANT=$(<"$work/grant") export PLOYZ_BUILD_GRANT : >"$events" sent=0 +# A cancelled job signals this script. The build stops, and is waited for, before the work dir and +# the Build Grant key in it go. +stop() { + if [[ -n ${build:-} ]]; then + kill -TERM "$build" 2>/dev/null || true + wait "$build" || true + fi + rm -rf "$work" +} +trap stop EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + # Every event line so far: prepare.sh's "Installing ployz", then the build's own. steps() { [[ ! -f "$work/install.jsonl" ]] || cat "$work/install.jsonl" @@ -63,6 +76,7 @@ while [[ -z $ended ]] && kill -0 "$build" 2>/dev/null; do done status=0 wait "$build" || status=$? +build= [[ -n $ended ]] || report "$(if [[ $status == 0 ]]; then jq -c '.platforms' "$work/result.json"; else echo '[]'; fi)" [[ $status == 0 ]] || exit "$status" diff --git a/grant.js b/grant.js new file mode 100644 index 0000000..8b58816 --- /dev/null +++ b/grant.js @@ -0,0 +1,44 @@ +// The Build Grant key is this runner's own: Cloud gets its public half, the holder, and answers with +// the Machine. Neither the key nor the grant is ever printed, except to mask the grant. +// key writes a new ed25519 seed, prints the holder +// holder prints the holder of a seed +// assemble writes the Build Grant and masks it +'use strict'; +const crypto = require('node:crypto'); +const fs = require('node:fs'); + +// An ed25519 PKCS#8 key is this fixed prefix and the 32-byte seed. +const pkcs8 = Buffer.from('302e020100300506032b657004220420', 'hex'); +const holder = (seed) => { + const key = crypto.createPrivateKey({ key: Buffer.concat([pkcs8, seed]), format: 'der', type: 'pkcs8' }); + return Buffer.from(crypto.createPublicKey(key).export({ format: 'jwk' }).x, 'base64url').toString('hex'); +}; + +// A parse error can quote the response, which holds build secrets: fail without a message. +try { + const [command, ...args] = process.argv.slice(2); + if (command === 'key') { + const { privateKey } = crypto.generateKeyPairSync('ed25519'); + const seed = Buffer.from(privateKey.export({ format: 'jwk' }).d, 'base64url'); + fs.writeFileSync(args[0], seed, { mode: 0o600 }); + console.log(holder(seed)); + } else if (command === 'holder') { + console.log(holder(fs.readFileSync(args[0]))); + } else if (command === 'assemble') { + const checkIn = JSON.parse(fs.readFileSync(args[0], 'utf8')); + let grant = checkIn.grant; + // A Machine that mints for a holder answers with its own identity, never the grant. Older ones send the grant. + if (checkIn.machine !== undefined) { + if (!/^[0-9a-f]{64}$/.test(checkIn.machine)) process.exit(1); + const seed = fs.readFileSync(args[1]); + grant = 'ployzgrant1:' + Buffer.concat([Buffer.from(checkIn.machine, 'hex'), seed]).toString('base64url'); + } + if (typeof grant !== 'string' || !grant.startsWith('ployzgrant1:')) process.exit(1); + console.log(`::add-mask::${grant}`); + fs.writeFileSync(args[2], grant, { mode: 0o600 }); + } else { + process.exit(2); + } +} catch { + process.exit(1); +} diff --git a/prepare.sh b/prepare.sh index 305ca8b..ec57c64 100755 --- a/prepare.sh +++ b/prepare.sh @@ -4,6 +4,7 @@ set -euo pipefail # shellcheck source=oidc.sh source "$(dirname "$0")/oidc.sh" +grant_js="$(dirname "$0")/grant.js" work="$RUNNER_TEMP/ployz-build" fail() { echo "::error::$1" @@ -11,6 +12,7 @@ fail() { } [[ "$(uname -s)" == Linux ]] || fail "Ployz builds need a Linux runner." +command -v node >/dev/null || fail "Ployz builds need Node.js on the runner." # A URL path segment: never "." or "..". [[ "$PLOYZ_BUILD_ID" =~ ^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$ ]] || fail "Invalid build id." cloud=${PLOYZ_CLOUD%/} @@ -34,21 +36,26 @@ if ! has_containerd_store; then has_containerd_store || fail "Docker's containerd image store could not be enabled." fi +# The Build Grant key never leaves the runner: Cloud mints the grant for its public half, the holder. +holder=$(node "$grant_js" key "$work/key") +jq -n --arg holder "$holder" '{holder: $holder}' >"$work/holder.json" + # GitHub's OIDC token proves this run to Cloud; its audience is the Cloud origin. oidc_token "$cloud" -cloud_post "$cloud/api/builds/$PLOYZ_BUILD_ID/check-in" "$work/check-in.json" || +cloud_post "$cloud/api/builds/$PLOYZ_BUILD_ID/check-in" "$work/check-in.json" \ + -H "Content-Type: application/json" --data-binary "@$work/holder.json" || fail "Ployz Cloud refused the check-in for build $PLOYZ_BUILD_ID ($cloud_error)." # Mask the grant and every build secret before anything can print them. Masks are per line. # Lines under 4 characters stay visible: masking a value like `0` would star out every 0 in the log. -jq -r '.grant, (.deployment.snapshots[]?.resolvedEnv // {} | .[])' "$work/check-in.json" | +node "$grant_js" assemble "$work/check-in.json" "$work/key" "$work/grant" || + fail "Check-in response has no Build Grant." +jq -r '.deployment.snapshots[]?.resolvedEnv // {} | .[]' "$work/check-in.json" | while IFS= read -r line; do if [[ ${#line} -ge 4 ]]; then echo "::add-mask::$line"; fi done -jq -e '.grant | type == "string" and startswith("ployzgrant1:")' "$work/check-in.json" >/dev/null || - fail "Check-in response has no Build Grant." commit=$(jq -r '.commit' "$work/check-in.json") [[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "Check-in response has no commit." [[ "$(jq -r '.fingerprint' "$work/check-in.json")" =~ ^[0-9a-f]{64}$ ]] || diff --git a/test.sh b/test.sh index d65d627..5eef574 100755 --- a/test.sh +++ b/test.sh @@ -10,6 +10,7 @@ mkdir -p "$tmp/stubs" "$tmp/runner" "$tmp/workspace" build=00000000-0000-4000-8000-00000000b101.web commit=0123456789abcdef0123456789abcdef01234567 fingerprint=$(printf 'f%.0s' {1..64}) +machine=$(printf '01%.0s' {1..32}) cat >"$tmp/check-in.json" <"$tmp/stubs/curl" <>"$tmp/posted.jsonl"; shift ;; -H|-X) shift ;; -*) ;; *) url=\$1 ;; esac + case "\$1" in -o) out=\$2; shift ;; -w) status=200; shift ;; --data-binary) data=\${2#@}; shift ;; -H|-X) shift ;; -*) ;; *) url=\$1 ;; esac shift done case "\$url" in @@ -27,14 +28,17 @@ case "\$url" in *audience=https%3A%2F%2Fcloud.test) echo '{"value":"oidc-token"}' ;; https://cloud.test/api/builds/$build/check-in) echo >>"$tmp/check-ins" + cp "\$data" "$tmp/check-in-body.json" # CHECK_IN=unreachable: the first try can't connect. CHECK_IN=refused: Cloud answers 409. # CHECK_IN=bad-version: Cloud names a ployz version that isn't a release tag. + # CHECK_IN=holder: the Machine minted for the holder, so Cloud sends the Machine, not the grant. if [ "\${CHECK_IN:-}" = unreachable ] && [ "\$(wc -l <"$tmp/check-ins")" = 1 ]; then echo "curl: (7) Failed to connect" >&2; exit 7; fi if [ "\${CHECK_IN:-}" = refused ]; then echo '{"_tag":"PublicError","code":"CONFLICT","message":"This build already started or is no longer wanted."}' >"\$out"; status=409 elif [ "\${CHECK_IN:-}" = bad-version ]; then jq '.ployzVersion = "0.1.0; curl evil.test"' "$tmp/check-in.json" >"\$out" + elif [ "\${CHECK_IN:-}" = holder ]; then jq --arg machine "$machine" 'del(.grant) + {machine: \$machine}' "$tmp/check-in.json" >"\$out" else cp "$tmp/check-in.json" "\$out"; fi ;; - https://cloud.test/api/builds/$build/steps) echo '{}' >"\$out" ;; + https://cloud.test/api/builds/$build/steps) cat "\$data" >>"$tmp/posted.jsonl"; echo '{}' >"\$out" ;; *) echo "unexpected curl \$url" >&2; exit 1 ;; esac printf '%s' "\$status" @@ -45,9 +49,15 @@ echo '[["driver-type","io.containerd.snapshotter.v1"]]' STUB cat >"$tmp/stubs/ployz" <&2; exit 1; } +[ "\$PLOYZ_BUILD_GRANT" = "\${GRANT:-ployzgrant1:grant-secret}" ] || { echo "grant not exported" >&2; exit 1; } [ "\$*" = "build --deployment $tmp/runner/ployz-build/deployment.json --commit $commit --fingerprint $fingerprint --source $tmp/workspace --events $tmp/runner/ployz-build/events.jsonl" ] || { echo "bad args: \$*" >&2; exit 1; } events=$tmp/runner/ployz-build/events.jsonl +# PLOYZ_HANG=1: a build still running when the job is cancelled. Stopped, it takes a moment, then notes whether the key is still there. +if [ -n "\${PLOYZ_HANG:-}" ]; then + trap 'sleep 0.3; [ -e "$tmp/runner/ployz-build/key" ] && : >"$tmp/stopped-with-key"; exit 143' TERM + echo "\$\$" >"$tmp/ployz.pid" + sleep 30 & wait +fi printf '%s\n' '{"at":1,"event":{"Build":{"Stage":"Building"}}}' '{"at":2,"event":{"Build":{"Stage":"Output"}}}' >>"\$events" # Long enough for a report while the build runs. sleep 0.5 @@ -77,6 +87,12 @@ for short in 0 abc; do if grep -qxF "::add-mask::$short" <<<"$log"; then echo "FAIL: short value $short masked" >&2; exit 1; fi done grep -qxF "commit=$commit" "$GITHUB_OUTPUT" || { echo "FAIL: commit output" >&2; exit 1; } +# The Build Grant key stays on the runner, readable only by its user; Cloud gets the holder. +key=$RUNNER_TEMP/ployz-build/key +[ "$(stat -c %a "$key")" = 600 ] || { echo "FAIL: the key file is not 0600" >&2; exit 1; } +[ "$(jq -r '.holder' "$tmp/check-in-body.json")" = "$(node "$here/grant.js" holder "$key")" ] || + { echo "FAIL: the check-in did not carry the holder" >&2; exit 1; } +grep -qE '^[0-9a-f]{64}$' <(jq -r '.holder' "$tmp/check-in-body.json") || { echo "FAIL: holder is not 64 hex" >&2; exit 1; } jq -e '.snapshots[0].resolvedEnv.TOKEN == "s3cr3t"' "$RUNNER_TEMP/ployz-build/deployment.json" >/dev/null [ -x "$(cat "$GITHUB_PATH")/ployz" ] || { echo "FAIL: ployz not installed" >&2; exit 1; } # The version the check-in named, not one the workflow chose. @@ -91,7 +107,9 @@ reported='length >= 2 and .[0].from == 0 and (.[0] | has("platforms") | not) jq -s -e "$reported"' and .[-1].platforms == ["linux/amd64"]' "$tmp/posted.jsonl" >/dev/null || { echo "FAIL: Build Steps not posted as they happened" >&2; cat "$tmp/posted.jsonl" >&2; exit 1; } [ -e "$tmp/reported-during-export" ] || { echo "FAIL: the final report waited for the cache export" >&2; exit 1; } +[ ! -e "$RUNNER_TEMP/ployz-build" ] || { echo "FAIL: the work dir outlived the build" >&2; exit 1; } # A failed cache export still passes the job, whose report went when the image was pushed. +"$here/prepare.sh" >/dev/null rm "$tmp/posted.jsonl" "$GITHUB_OUTPUT" PLOYZ_EXPORT_FAIL=1 "$here/build.sh" >/dev/null 2>&1 || { echo "FAIL: a failed cache export failed the job" >&2; exit 1; } [ -e "$tmp/reported-during-export" ] || { echo "FAIL: the final report waited for a failing cache export" >&2; exit 1; } @@ -99,6 +117,7 @@ jq -s -e "$reported"' and .[-1].platforms == ["linux/amd64"]' "$tmp/posted.jsonl { echo "FAIL: a failed cache export changed the report" >&2; cat "$tmp/posted.jsonl" >&2; exit 1; } grep -qxF "digest=sha256:abc" "$GITHUB_OUTPUT" || { echo "FAIL: digest output after a failed cache export" >&2; exit 1; } # A failed build still reports its steps, then fails the job. +"$here/prepare.sh" >/dev/null rm "$tmp/posted.jsonl" if PLOYZ_FAIL=1 "$here/build.sh" >/dev/null 2>&1; then echo "FAIL: a failed build passed" >&2; exit 1; fi jq -s -e "$reported"' and .[-1].platforms == []' "$tmp/posted.jsonl" >/dev/null || { echo "FAIL: a failed build did not report" >&2; exit 1; } @@ -129,4 +148,44 @@ for id in . .. ../x a/b "a b" "$(printf 'a%.0s' {1..129})"; do grep -qxF "::error::Invalid build id." <<<"$log" || { echo "FAIL: build id $id not refused as invalid" >&2; echo "$log" >&2; exit 1; } done if PLOYZ_CLOUD=https://evil.test/path "$here/prepare.sh" >/dev/null 2>&1; then echo "FAIL: accepted a Cloud URL with a path" >&2; exit 1; fi + +# RFC 8032 test 1: the seed's public key is the holder Cloud mints for. +node -e 'require("fs").writeFileSync(process.argv[1], Buffer.from(process.argv[2], "hex"))' "$tmp/fixture.key" \ + 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60 +[ "$(node "$here/grant.js" holder "$tmp/fixture.key")" = d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a ] || + { echo "FAIL: holder of the RFC 8032 seed" >&2; exit 1; } +jq -n --arg machine "$machine" '{machine: $machine}' >"$tmp/fixture.json" +fixture=ployzgrant1:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQGdYbGd7_1aYLqESvSS7CzEREnFaXsyaRlwO6wDHK5_YA +[ "$(node "$here/grant.js" assemble "$tmp/fixture.json" "$tmp/fixture.key" "$tmp/fixture.grant")" = "::add-mask::$fixture" ] || + { echo "FAIL: the assembled grant was not masked" >&2; exit 1; } +[ "$(cat "$tmp/fixture.grant")" = "$fixture" ] || { echo "FAIL: assembled grant" >&2; exit 1; } +jq -n '{machine: "not hex"}' >"$tmp/fixture.json" +if node "$here/grant.js" assemble "$tmp/fixture.json" "$tmp/fixture.key" "$tmp/fixture.grant" >/dev/null 2>&1; then + echo "FAIL: assembled a grant for a malformed Machine" >&2; exit 1 +fi + +# A Machine that minted for the holder: the runner assembles the grant from its own key, masked first. +log=$(CHECK_IN=holder "$here/prepare.sh") +grant="ployzgrant1:$({ printf '\1%.0s' {1..32}; cat "$key"; } | base64 -w0 | tr '+/' '-_' | tr -d =)" +[ "$(head -n 2 <<<"$log" | tail -n 1)" = "::add-mask::$grant" ] || { echo "FAIL: the assembled grant was not masked first" >&2; echo "$log" >&2; exit 1; } +rm "$tmp/posted.jsonl" +GRANT=$grant "$here/build.sh" >/dev/null || { echo "FAIL: build with the assembled grant" >&2; exit 1; } +jq -s -e "$reported"' and .[-1].platforms == ["linux/amd64"]' "$tmp/posted.jsonl" >/dev/null || { echo "FAIL: build with the assembled grant did not report" >&2; exit 1; } + +# A cancelled job stops the build and waits for it before the key goes. +"$here/prepare.sh" >/dev/null +rm -f "$tmp/ployz.pid" +PLOYZ_HANG=1 "$here/build.sh" >/dev/null 2>&1 & +runner=$! +for _ in {1..50}; do [ -s "$tmp/ployz.pid" ] && break; sleep 0.1; done +[ -s "$tmp/ployz.pid" ] || { echo "FAIL: the hanging build did not start" >&2; exit 1; } +kill -TERM "$runner" +SECONDS=0 +status=0 +wait "$runner" || status=$? +[ "$SECONDS" -lt 5 ] || { echo "FAIL: a cancelled job did not stop the build" >&2; exit 1; } +[ -e "$tmp/stopped-with-key" ] || { echo "FAIL: the key went before the build stopped" >&2; exit 1; } +[ "$status" = 143 ] || { echo "FAIL: a cancelled build exited $status" >&2; exit 1; } +if kill -0 "$(cat "$tmp/ployz.pid")" 2>/dev/null; then echo "FAIL: the build outlived the cancelled job" >&2; exit 1; fi +[ ! -e "$RUNNER_TEMP/ployz-build" ] || { echo "FAIL: the key outlived the cancelled job" >&2; exit 1; } echo "ok" From 684fb8091a3b2a853e2e8dd3e477d65a62856d65 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Sat, 24 Oct 2026 14:22:20 +0800 Subject: [PATCH 2/4] Keep the build's scratch in the work dir and test a cancel that dies on TERM The cancel test's stub trapped TERM itself, which hid a ployz build that died on TERM without cleanup. It now dies on TERM unless it models a graceful stop, and ployz build runs with TMPDIR under the work dir, so opened Sealed values go with it on any exit. A Cloud-held grant prints a notice. Key and grant files are 0600 even when one was already there, and a key that is not a 32-byte seed is refused. --- README.md | 2 +- build.sh | 4 +++- grant.js | 36 ++++++++++++++++++++++++-------- prepare.sh | 8 +++++-- test.sh | 61 +++++++++++++++++++++++++++++++++++++++--------------- 5 files changed, 81 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 7c2b9d7..e5adb3f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Inputs are visible in GitHub, so none of them is secret. The Action builds the grant from `machine` and its own key. Older Machines answer `grant` instead of `machine`. The token, the grant, and every `resolvedEnv` value are masked (`::add-mask::`, per line) before anything else runs. Lines under 4 characters are not: masking `0` would star out every 0 in the log. A check-in that can't reach Cloud is retried; an HTTP error is not, and the job fails with Cloud's status and message. 4. Installs exactly `ployzVersion` from `https://ployz.sh`: the version of the Cloud process that computed `fingerprint`, so they match even while Cloud rolls out a new version. The installer verifies the release checksum. The install is timed as the first Build Step, "Installing ployz". If it fails, the Action sends Cloud a final report with that step failed and naming the version (`installFailed`), so Cloud moves the build on to the next Builder. 5. Checks out `commit` without persisting credentials. -6. Runs `PLOYZ_BUILD_GRANT=… ployz build --deployment --commit --fingerprint --events `. The deployment file and the key live in `$RUNNER_TEMP` and are deleted when the job ends, pass or fail. A cancelled job stops `ployz build` first. +6. Runs `PLOYZ_BUILD_GRANT=… ployz build --deployment --commit --fingerprint --events `. The deployment file, the key, and the build's scratch (`TMPDIR`) live in `$RUNNER_TEMP` and are deleted when the job ends, pass or fail. A cancelled job stops `ployz build` first. 7. Reports the Build Steps while it builds, every few seconds, each time with a fresh OIDC token: `POST {cloud}/api/builds/{build}/steps` with `{"from": , "events": []}`, where `from` is the 0-based line the batch starts at. The lines are the "Installing ployz" step, then `ployz build --events`, which includes the push and its per-layer progress. Cloud files only lines it hasn't taken, so a retried batch is harmless. As soon as `ployz build` prints its result (the image is in the Machine), or when it fails, the last batch adds `"platforms": [...]`; empty means the build failed. That report settles the build, and Cloud takes no more. `ployz build` then uploads the build cache to the GitHub Actions cache; a failed upload is only a warning. Output `digest` is the manifest digest the Machine received. Cloud does not trust it: it reads the pushed digest from the Machine when it ends the grant. diff --git a/build.sh b/build.sh index f179d19..891aa41 100755 --- a/build.sh +++ b/build.sh @@ -53,9 +53,11 @@ report() { fi } +# Build scratch, opened Sealed values included, stays under the work dir, which goes on any exit. +install -d -m 0700 "$work/tmp" # stdout is one JSON line, printed once the image is pushed; build logs go to stderr and, as # events, to a file. After the push, ployz goes on uploading build cache, which Cloud doesn't wait for. -ployz build \ +TMPDIR="$work/tmp" ployz build \ --deployment "$work/deployment.json" \ --commit "$(jq -r '.commit' "$work/check-in.json")" \ --fingerprint "$(jq -r '.fingerprint' "$work/check-in.json")" \ diff --git a/grant.js b/grant.js index 8b58816..1ed0793 100644 --- a/grant.js +++ b/grant.js @@ -13,32 +13,50 @@ const holder = (seed) => { const key = crypto.createPrivateKey({ key: Buffer.concat([pkcs8, seed]), format: 'der', type: 'pkcs8' }); return Buffer.from(crypto.createPublicKey(key).export({ format: 'jwk' }).x, 'base64url').toString('hex'); }; +// Each refusal is a fixed line that names no secret. +class Refusal extends Error {} +const readSeed = (path) => { + const seed = fs.readFileSync(path); + if (seed.length !== 32) throw new Refusal('The Build Grant key is not 32 bytes.'); + return seed; +}; +// A fresh file, so 0600 holds even when one was already there. +const writeSecret = (path, data) => { + fs.rmSync(path, { force: true }); + fs.writeFileSync(path, data, { mode: 0o600, flag: 'wx' }); +}; -// A parse error can quote the response, which holds build secrets: fail without a message. +// A parse error can quote the response, which holds build secrets: print only fixed lines. try { const [command, ...args] = process.argv.slice(2); if (command === 'key') { const { privateKey } = crypto.generateKeyPairSync('ed25519'); const seed = Buffer.from(privateKey.export({ format: 'jwk' }).d, 'base64url'); - fs.writeFileSync(args[0], seed, { mode: 0o600 }); + writeSecret(args[0], seed); console.log(holder(seed)); } else if (command === 'holder') { - console.log(holder(fs.readFileSync(args[0]))); + console.log(holder(readSeed(args[0]))); } else if (command === 'assemble') { - const checkIn = JSON.parse(fs.readFileSync(args[0], 'utf8')); + let checkIn; + try { + checkIn = JSON.parse(fs.readFileSync(args[0], 'utf8')); + } catch { + throw new Refusal('Check-in response has no Build Grant.'); + } let grant = checkIn.grant; // A Machine that mints for a holder answers with its own identity, never the grant. Older ones send the grant. if (checkIn.machine !== undefined) { - if (!/^[0-9a-f]{64}$/.test(checkIn.machine)) process.exit(1); - const seed = fs.readFileSync(args[1]); + if (!/^[0-9a-f]{64}$/.test(checkIn.machine)) throw new Refusal('Check-in response has no valid Machine.'); + const seed = readSeed(args[1]); grant = 'ployzgrant1:' + Buffer.concat([Buffer.from(checkIn.machine, 'hex'), seed]).toString('base64url'); } - if (typeof grant !== 'string' || !grant.startsWith('ployzgrant1:')) process.exit(1); + if (typeof grant !== 'string' || !grant.startsWith('ployzgrant1:')) throw new Refusal('Check-in response has no Build Grant.'); console.log(`::add-mask::${grant}`); - fs.writeFileSync(args[2], grant, { mode: 0o600 }); + writeSecret(args[2], grant); } else { process.exit(2); } -} catch { +} catch (error) { + console.log(`::error::${error instanceof Refusal ? error.message : 'The Build Grant key could not be made or read.'}`); process.exit(1); } diff --git a/prepare.sh b/prepare.sh index ec57c64..2dde516 100755 --- a/prepare.sh +++ b/prepare.sh @@ -49,12 +49,16 @@ cloud_post "$cloud/api/builds/$PLOYZ_BUILD_ID/check-in" "$work/check-in.json" \ # Mask the grant and every build secret before anything can print them. Masks are per line. # Lines under 4 characters stay visible: masking a value like `0` would star out every 0 in the log. -node "$grant_js" assemble "$work/check-in.json" "$work/key" "$work/grant" || - fail "Check-in response has no Build Grant." +node "$grant_js" assemble "$work/check-in.json" "$work/key" "$work/grant" || exit 1 jq -r '.deployment.snapshots[]?.resolvedEnv // {} | .[]' "$work/check-in.json" | while IFS= read -r line; do if [[ ${#line} -ge 4 ]]; then echo "::add-mask::$line"; fi done +# Cloud answered with the grant, not the Machine: one of them predates runner-held keys. Such a grant opens no Sealed +# values, so the Machine refuses `/ployz/sealed` and a build that reads one fails closed. +if ! jq -e 'has("machine")' "$work/check-in.json" >/dev/null; then + echo "::notice::This build used a Cloud-held Build Grant." +fi commit=$(jq -r '.commit' "$work/check-in.json") [[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "Check-in response has no commit." diff --git a/test.sh b/test.sh index 5eef574..a89b622 100755 --- a/test.sh +++ b/test.sh @@ -51,11 +51,15 @@ cat >"$tmp/stubs/ployz" <&2; exit 1; } [ "\$*" = "build --deployment $tmp/runner/ployz-build/deployment.json --commit $commit --fingerprint $fingerprint --source $tmp/workspace --events $tmp/runner/ployz-build/events.jsonl" ] || { echo "bad args: \$*" >&2; exit 1; } +[ "\$TMPDIR" = $tmp/runner/ployz-build/tmp ] && [ "\$(stat -c %a "\$TMPDIR")" = 700 ] || { echo "TMPDIR is not the work dir's" >&2; exit 1; } events=$tmp/runner/ployz-build/events.jsonl -# PLOYZ_HANG=1: a build still running when the job is cancelled. Stopped, it takes a moment, then notes whether the key is still there. +# PLOYZ_HANG: a build still running, an opened Sealed value in its scratch, when the job is cancelled. +# PLOYZ_HANG=1 dies on TERM. PLOYZ_HANG=graceful takes a moment to stop, then notes whether the key is still there. if [ -n "\${PLOYZ_HANG:-}" ]; then - trap 'sleep 0.3; [ -e "$tmp/runner/ployz-build/key" ] && : >"$tmp/stopped-with-key"; exit 143' TERM + mkdir -p "\$TMPDIR/ployz-build-1/private/sealed" && echo sealed-value >"\$TMPDIR/ployz-build-1/private/sealed/TOKEN" echo "\$\$" >"$tmp/ployz.pid" + [ "\$PLOYZ_HANG" = graceful ] || exec sleep 30 + trap 'kill \$!; sleep 0.3; [ -e "$tmp/runner/ployz-build/key" ] && : >"$tmp/stopped-with-key"; exit 143' TERM sleep 30 & wait fi printf '%s\n' '{"at":1,"event":{"Build":{"Stage":"Building"}}}' '{"at":2,"event":{"Build":{"Stage":"Output"}}}' >>"\$events" @@ -87,6 +91,8 @@ for short in 0 abc; do if grep -qxF "::add-mask::$short" <<<"$log"; then echo "FAIL: short value $short masked" >&2; exit 1; fi done grep -qxF "commit=$commit" "$GITHUB_OUTPUT" || { echo "FAIL: commit output" >&2; exit 1; } +# The legacy grant, minted by Cloud, opens no Sealed values; the job says which grant it used. +grep -qxF "::notice::This build used a Cloud-held Build Grant." <<<"$log" || { echo "FAIL: a Cloud-held grant went unnoticed" >&2; exit 1; } # The Build Grant key stays on the runner, readable only by its user; Cloud gets the holder. key=$RUNNER_TEMP/ployz-build/key [ "$(stat -c %a "$key")" = 600 ] || { echo "FAIL: the key file is not 0600" >&2; exit 1; } @@ -159,6 +165,23 @@ fixture=ployzgrant1:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQGdYbGd7_1aYLqESvSS [ "$(node "$here/grant.js" assemble "$tmp/fixture.json" "$tmp/fixture.key" "$tmp/fixture.grant")" = "::add-mask::$fixture" ] || { echo "FAIL: the assembled grant was not masked" >&2; exit 1; } [ "$(cat "$tmp/fixture.grant")" = "$fixture" ] || { echo "FAIL: assembled grant" >&2; exit 1; } +# A key or grant file already there, readable by others, ends up 0600 all the same. +for file in fixture.grant fresh.key; do echo stale >"$tmp/$file"; chmod 644 "$tmp/$file"; done +(umask 022; node "$here/grant.js" assemble "$tmp/fixture.json" "$tmp/fixture.key" "$tmp/fixture.grant" >/dev/null) +(umask 022; node "$here/grant.js" key "$tmp/fresh.key" >/dev/null) +for file in fixture.grant fresh.key; do + [ "$(stat -c %a "$tmp/$file")" = 600 ] || { echo "FAIL: $file is not 0600 after a rewrite" >&2; exit 1; } +done +[ "$(stat -c %s "$tmp/fresh.key")" = 32 ] || { echo "FAIL: the new key is not a 32-byte seed" >&2; exit 1; } +# A key that is not a 32-byte seed is refused with a fixed line; no grant is written. +head -c 31 "$tmp/fixture.key" >"$tmp/short.key" +rm "$tmp/fixture.grant" +for command in "assemble $tmp/fixture.json $tmp/short.key $tmp/fixture.grant" "holder $tmp/short.key"; do + # shellcheck disable=SC2086 # the command's words + if log=$(node "$here/grant.js" $command); then echo "FAIL: accepted a 31-byte key ($command)" >&2; exit 1; fi + [ "$log" = "::error::The Build Grant key is not 32 bytes." ] || { echo "FAIL: a 31-byte key refused as: $log" >&2; exit 1; } +done +[ ! -e "$tmp/fixture.grant" ] || { echo "FAIL: wrote a grant from a 31-byte key" >&2; exit 1; } jq -n '{machine: "not hex"}' >"$tmp/fixture.json" if node "$here/grant.js" assemble "$tmp/fixture.json" "$tmp/fixture.key" "$tmp/fixture.grant" >/dev/null 2>&1; then echo "FAIL: assembled a grant for a malformed Machine" >&2; exit 1 @@ -168,24 +191,28 @@ fi log=$(CHECK_IN=holder "$here/prepare.sh") grant="ployzgrant1:$({ printf '\1%.0s' {1..32}; cat "$key"; } | base64 -w0 | tr '+/' '-_' | tr -d =)" [ "$(head -n 2 <<<"$log" | tail -n 1)" = "::add-mask::$grant" ] || { echo "FAIL: the assembled grant was not masked first" >&2; echo "$log" >&2; exit 1; } +if grep -qF "::notice::" <<<"$log"; then echo "FAIL: a runner-held grant was noticed as Cloud-held" >&2; exit 1; fi rm "$tmp/posted.jsonl" GRANT=$grant "$here/build.sh" >/dev/null || { echo "FAIL: build with the assembled grant" >&2; exit 1; } jq -s -e "$reported"' and .[-1].platforms == ["linux/amd64"]' "$tmp/posted.jsonl" >/dev/null || { echo "FAIL: build with the assembled grant did not report" >&2; exit 1; } -# A cancelled job stops the build and waits for it before the key goes. -"$here/prepare.sh" >/dev/null -rm -f "$tmp/ployz.pid" -PLOYZ_HANG=1 "$here/build.sh" >/dev/null 2>&1 & -runner=$! -for _ in {1..50}; do [ -s "$tmp/ployz.pid" ] && break; sleep 0.1; done -[ -s "$tmp/ployz.pid" ] || { echo "FAIL: the hanging build did not start" >&2; exit 1; } -kill -TERM "$runner" -SECONDS=0 -status=0 -wait "$runner" || status=$? -[ "$SECONDS" -lt 5 ] || { echo "FAIL: a cancelled job did not stop the build" >&2; exit 1; } +# A cancelled job stops the build and waits for it before the work dir, the key and the build's scratch in it, goes. +cancel() { + "$here/prepare.sh" >/dev/null + rm -f "$tmp/ployz.pid" "$tmp/stopped-with-key" + PLOYZ_HANG=$1 "$here/build.sh" >/dev/null 2>&1 & + local runner=$! status=0 + for _ in {1..50}; do [ -s "$tmp/ployz.pid" ] && break; sleep 0.1; done + [ -s "$tmp/ployz.pid" ] || { echo "FAIL: the hanging build did not start ($1)" >&2; exit 1; } + kill -TERM "$runner" + SECONDS=0 + wait "$runner" || status=$? + [ "$SECONDS" -lt 5 ] || { echo "FAIL: a cancelled job did not stop the build ($1)" >&2; exit 1; } + [ "$status" = 143 ] || { echo "FAIL: a cancelled build exited $status ($1)" >&2; exit 1; } + if kill -0 "$(cat "$tmp/ployz.pid")" 2>/dev/null; then echo "FAIL: the build outlived the cancelled job ($1)" >&2; exit 1; fi + [ ! -e "$RUNNER_TEMP/ployz-build" ] || { echo "FAIL: the work dir outlived the cancelled job ($1)" >&2; exit 1; } +} +cancel 1 +cancel graceful [ -e "$tmp/stopped-with-key" ] || { echo "FAIL: the key went before the build stopped" >&2; exit 1; } -[ "$status" = 143 ] || { echo "FAIL: a cancelled build exited $status" >&2; exit 1; } -if kill -0 "$(cat "$tmp/ployz.pid")" 2>/dev/null; then echo "FAIL: the build outlived the cancelled job" >&2; exit 1; fi -[ ! -e "$RUNNER_TEMP/ployz-build" ] || { echo "FAIL: the key outlived the cancelled job" >&2; exit 1; } echo "ok" From fe456e25c775625272d018b39b876d7f29d4f190 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Sat, 24 Oct 2026 16:47:24 +0800 Subject: [PATCH 3/4] Print Build Grant key refusals to stderr so a captured call still shows them --- grant.js | 3 ++- test.sh | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/grant.js b/grant.js index 1ed0793..d965eab 100644 --- a/grant.js +++ b/grant.js @@ -57,6 +57,7 @@ try { process.exit(2); } } catch (error) { - console.log(`::error::${error instanceof Refusal ? error.message : 'The Build Grant key could not be made or read.'}`); + // stderr, so a caller that captures stdout still shows it in the job log. + console.error(`::error::${error instanceof Refusal ? error.message : 'The Build Grant key could not be made or read.'}`); process.exit(1); } diff --git a/test.sh b/test.sh index a89b622..2b982ae 100755 --- a/test.sh +++ b/test.sh @@ -178,8 +178,10 @@ head -c 31 "$tmp/fixture.key" >"$tmp/short.key" rm "$tmp/fixture.grant" for command in "assemble $tmp/fixture.json $tmp/short.key $tmp/fixture.grant" "holder $tmp/short.key"; do # shellcheck disable=SC2086 # the command's words - if log=$(node "$here/grant.js" $command); then echo "FAIL: accepted a 31-byte key ($command)" >&2; exit 1; fi - [ "$log" = "::error::The Build Grant key is not 32 bytes." ] || { echo "FAIL: a 31-byte key refused as: $log" >&2; exit 1; } + if out=$(node "$here/grant.js" $command 2>"$tmp/stderr"); then echo "FAIL: accepted a 31-byte key ($command)" >&2; exit 1; fi + # On stderr: the caller's command substitution would swallow stdout. + [ -z "$out" ] && [ "$(cat "$tmp/stderr")" = "::error::The Build Grant key is not 32 bytes." ] || + { echo "FAIL: a 31-byte key refused as: $out / $(cat "$tmp/stderr")" >&2; exit 1; } done [ ! -e "$tmp/fixture.grant" ] || { echo "FAIL: wrote a grant from a 31-byte key" >&2; exit 1; } jq -n '{machine: "not hex"}' >"$tmp/fixture.json" @@ -187,6 +189,13 @@ if node "$here/grant.js" assemble "$tmp/fixture.json" "$tmp/fixture.key" "$tmp/f echo "FAIL: assembled a grant for a malformed Machine" >&2; exit 1 fi +# A key that can't be written fails the job with its reason in the log. +rm -f "$RUNNER_TEMP/ployz-build/key" && mkdir -p "$RUNNER_TEMP/ployz-build/key/in-the-way" +if log=$("$here/prepare.sh" 2>&1); then echo "FAIL: passed without a Build Grant key" >&2; exit 1; fi +grep -qxF "::error::The Build Grant key could not be made or read." <<<"$log" || + { echo "FAIL: a failed key did not say why" >&2; echo "$log" >&2; exit 1; } +rm -r "$RUNNER_TEMP/ployz-build/key" + # A Machine that minted for the holder: the runner assembles the grant from its own key, masked first. log=$(CHECK_IN=holder "$here/prepare.sh") grant="ployzgrant1:$({ printf '\1%.0s' {1..32}; cat "$key"; } | base64 -w0 | tr '+/' '-_' | tr -d =)" From 35e260216d961a478aee792cac9cf2ca036d86ee Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Sat, 24 Oct 2026 22:43:45 +0800 Subject: [PATCH 4/4] Assert the short-key refusal with if/then so shellcheck passes --- test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test.sh b/test.sh index 2b982ae..d457592 100755 --- a/test.sh +++ b/test.sh @@ -180,8 +180,9 @@ for command in "assemble $tmp/fixture.json $tmp/short.key $tmp/fixture.grant" "h # shellcheck disable=SC2086 # the command's words if out=$(node "$here/grant.js" $command 2>"$tmp/stderr"); then echo "FAIL: accepted a 31-byte key ($command)" >&2; exit 1; fi # On stderr: the caller's command substitution would swallow stdout. - [ -z "$out" ] && [ "$(cat "$tmp/stderr")" = "::error::The Build Grant key is not 32 bytes." ] || - { echo "FAIL: a 31-byte key refused as: $out / $(cat "$tmp/stderr")" >&2; exit 1; } + if [ -n "$out" ] || [ "$(cat "$tmp/stderr")" != "::error::The Build Grant key is not 32 bytes." ]; then + echo "FAIL: a 31-byte key refused as: $out / $(cat "$tmp/stderr")" >&2; exit 1 + fi done [ ! -e "$tmp/fixture.grant" ] || { echo "FAIL: wrote a grant from a 31-byte key" >&2; exit 1; } jq -n '{machine: "not hex"}' >"$tmp/fixture.json"