Skip to content

Commit eaa9d1e

Browse files
authored
fix(release): build clients natively on unprivileged runners (#5068)
2 parents b60bcdb + a7a4c57 commit eaa9d1e

26 files changed

Lines changed: 863 additions & 480 deletions

‎.github/NPM_RUNNER.md‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# NPM release runners
2+
3+
NPM release compilation uses `[self-hosted, Linux, X64, npm-build]` in the
4+
restricted `platform-npm-releases` runner group. Publishing
5+
continues on GitHub-hosted Ubuntu with OIDC; the builder receives no publishing
6+
credentials. The `npm-release-build` action is shared by releases and image
7+
validation so both compile and pack with the same setup.
8+
9+
## Image contract
10+
11+
`.github/runner-requirements.json` pins the complete Linux image requirements and
12+
recipe commit. The job runs `ci-image-contract verify` before compiling. The
13+
runner must have `/opt/client-codegen` matching `packages/dapi-grpc/codegen.json`.
14+
Its protobuf 3.18.1 compiler is intentionally separate from Rust's protoc 32.0.
15+
TypeScript generation comes from the workspace's pinned `ts-protoc-gen` dependency.
16+
17+
Image-owned native dependencies are verified, never installed using sudo. Rust,
18+
Node and the pinned WASM tools use writable runner/user locations. Cargo targets
19+
remain outside the checkout; each release starts with a fresh workspace. The
20+
runner needs no Docker CLI/socket or KVM device.
21+
22+
## Provisioning and promotion
23+
24+
Use the reviewed `dashpay/dash-selfhosted-image` recipe and a tested immutable
25+
image digest, not a moving tag. Register dedicated release capacity with `npm-build` only after
26+
the NPM validation workflow succeeds on that image. Drain old registrations before
27+
replacement; retain their image/configuration for rollback. Old release tags
28+
still contain their original workflows and do not automatically gain this fix.
29+
30+
Requirements-changing PRs select a candidate label bound to the complete PR head
31+
and image digest. The image controller must support the `npm` job kind and
32+
`.github/workflows/npm-runner-validation.yml`. Manifests requesting native client
33+
generation require successful Rust, Kotlin and NPM candidate jobs for promotion;
34+
skipped fork jobs do not qualify. Existing same-repository/trusted-fork guards
35+
remain in effect.
36+
37+
The trusted `runner-image-candidate.yml` bootstrap, controller and Rust/Kotlin
38+
candidate routing must be installed on each consuming branch before candidate
39+
promotion can work. Platform PRs #4702 and #4912 establish those pieces; reconcile
40+
their requirements/selector files with this NPM extension when landing them. In
41+
particular, update both the bootstrap's reusable-workflow SHA and its
42+
`control_revision` to a reviewed image-repository revision supporting
43+
`client_codegen` and `npm`. Merely changing `recipe_revision` is insufficient.
44+
The default `v4.2-dev` and `v4.3-dev` branches must each use an explicit compatible
45+
manifest; this change does not alter an existing release tag or deploy a runner.
46+
47+
## Verification
48+
49+
`npm-runner-validation.yml` runs the real release build and packing action, DAPI
50+
unit tests, and a byte-for-byte check that packed Node/web clients match the
51+
freshly generated files. It uploads tarballs but never publishes them.
52+
`test-client-codegen.yml` also builds the native compilers on hosted Linux/macOS,
53+
checks committed generated output, tests failure recovery and validates packing.
54+
55+
Local setup and generator test commands are in `packages/dapi-grpc/README.md`.
56+
57+
## Separate PR and release state
58+
59+
The `platform-npm-releases` organization runner group must select only the
60+
`dashpay/platform` repository and restrict execution to:
61+
62+
```text
63+
dashpay/platform/.github/workflows/release-npm-build.yml@refs/heads/v4.2-dev
64+
```
65+
66+
Protect that branch with the normal maintainer review policy. `release.yml`
67+
invokes that protected reusable workflow; the reusable workflow rejects PR
68+
callers and arbitrary branch dispatches before checkout. A PR cannot select the
69+
release group by changing its own workflow to request the `npm-build` label.
70+
The group-level selected-workflow restriction is a required operator setting,
71+
not something a repository workflow can grant itself.
72+
73+
Use separate runner containers/VMs and separate registration, workspace, HOME,
74+
Cargo registry and target-cache storage for PR and release pools. Do not mount
75+
the same cache volumes into both pools. Release caches can persist between
76+
releases; no PR may write them. Ordinary PR validation uses `npm-pr`; image
77+
candidates continue to use fresh one-job registrations and volumes. Do not add
78+
`npm-pr`, `rust-ci` or `kotlin-ci` to the release registration.
79+
80+
For another maintained branch, create its reviewed protected reusable-workflow
81+
ref and corresponding group policy explicitly. Do not wildcard the workflow
82+
restriction or allow PR refs. Validate the policy by attempting a PR job that
83+
requests the release group: it must be rejected, while a permitted release dry
84+
run succeeds. The workflow guard is defense in depth; enabling the release
85+
pool without its group restriction does not establish this boundary.

‎.github/actionlint.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ self-hosted-runner:
99
labels:
1010
- rust-ci
1111
- kotlin-ci
12+
- npm-build
13+
- npm-pr
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
name: Build release NPM packages
2+
description: Compile and pack on the provisioned unprivileged Linux image
3+
inputs:
4+
cache-name:
5+
description: Persistent Cargo target cache name
6+
default: release-npm-target
7+
runs:
8+
using: composite
9+
steps:
10+
- name: Verify provisioned runner dependencies
11+
shell: bash
12+
run: |
13+
set -euo pipefail
14+
15+
ci-image-contract verify .github/runner-requirements.json
16+
python3 packages/dapi-grpc/scripts/setup-codegen.py
17+
for pkg in build-essential cmake curl jq libgmp-dev libssl-dev pkg-config python3 unzip zip; do
18+
dpkg-query -W -f='${Status}' "$pkg" | grep -Fx 'install ok installed'
19+
done
20+
test -x "$HOME/.cargo/bin/rustup"
21+
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
22+
23+
- name: Setup Rust
24+
uses: ./.github/actions/rust
25+
with:
26+
target: wasm32-unknown-unknown
27+
# The runner persists the Cargo registry between runs.
28+
cache: 'false'
29+
system-dependencies: verify
30+
31+
# The Rust action above reuses a protoc left in ~/.local by earlier jobs.
32+
# Override it with a fresh, checksum-verified copy for the release build;
33+
# prost-build reads PROTOC first.
34+
- name: Install protoc v32.0
35+
env:
36+
PROTOC_SHA256: 7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88
37+
shell: bash
38+
run: |
39+
set -euo pipefail
40+
PROTOC_DIR="$RUNNER_TEMP/protoc-32.0"
41+
curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \
42+
https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip
43+
echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | sha256sum -c -
44+
rm -rf "$PROTOC_DIR"
45+
unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR"
46+
echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV"
47+
echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH"
48+
"$PROTOC_DIR/bin/protoc" --version
49+
50+
- name: Prepare release Cargo target cache
51+
uses: ./.github/actions/release-cargo-target-cache
52+
with:
53+
name: ${{ inputs.cache-name }}
54+
55+
- name: Setup Node.JS
56+
uses: ./.github/actions/nodejs
57+
58+
- name: Install Cargo binstall
59+
uses: cargo-bins/cargo-binstall@v1.3.1
60+
61+
# Always reinstalled, never trusted from an earlier job: a binary left on
62+
# this persistent runner can print the pinned version and still be
63+
# something else.
64+
- name: Install wasm-bindgen-cli
65+
shell: bash
66+
run: |
67+
set -euo pipefail
68+
cargo binstall wasm-bindgen-cli@0.2.108 --no-confirm --force
69+
wasm-bindgen --version | grep -Fxq 'wasm-bindgen 0.2.108'
70+
71+
- name: Install wasm-pack
72+
shell: bash
73+
run: |
74+
set -euo pipefail
75+
cargo binstall wasm-pack@0.15.0 --no-confirm --force
76+
wasm-pack --version | grep -Fxq 'wasm-pack 0.15.0'
77+
78+
# Fresh, checksum-verified copy in this job's temp directory rather than
79+
# one left in ~/.local by an earlier job.
80+
- name: Install Binaryen
81+
env:
82+
BINARYEN_SHA256: c90e0e295e8f8484ba5b47da92f26e5d1d18db6cd2fcc0c5cc265a5a73609f17
83+
shell: bash
84+
run: |
85+
set -euo pipefail
86+
ARCHIVE="$RUNNER_TEMP/binaryen-version_121-x86_64-linux.tar.gz"
87+
curl -fsSL -o "$ARCHIVE" \
88+
https://github.com/WebAssembly/binaryen/releases/download/version_121/binaryen-version_121-x86_64-linux.tar.gz
89+
echo "$BINARYEN_SHA256 $ARCHIVE" | sha256sum -c -
90+
rm -rf "$RUNNER_TEMP/binaryen-version_121"
91+
tar -xzf "$ARCHIVE" -C "$RUNNER_TEMP"
92+
echo "$RUNNER_TEMP/binaryen-version_121/bin" >> "$GITHUB_PATH"
93+
94+
- name: Build packages
95+
shell: bash
96+
run: yarn build
97+
env:
98+
CARGO_BUILD_PROFILE: release
99+
100+
- name: Ignore only already cached artifacts
101+
shell: bash
102+
run: |
103+
find . -name '.gitignore' -exec rm -f {} +
104+
{
105+
echo ".yarn"
106+
echo "target"
107+
echo "node_modules"
108+
echo ".nyc_output"
109+
echo ".idea"
110+
echo ".ultra.cache.json"
111+
echo "db/*"
112+
echo "npm-packages"
113+
} >> .gitignore
114+
115+
- name: Pack public workspaces
116+
shell: bash
117+
run: |
118+
mkdir -p npm-packages
119+
yarn workspaces foreach --all --no-private --parallel pack \
120+
--out "$GITHUB_WORKSPACE/npm-packages/%s-%v.tgz"
121+
test -n "$(find npm-packages -maxdepth 1 -type f -name '*.tgz' -print -quit)"

‎.github/actions/rust/action.yaml‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ inputs:
1212
components:
1313
description: List of additional Rust toolchain components to install
1414
required: false
15+
system-dependencies:
16+
description: Install native packages on hosted runners, or verify a provisioned image
17+
default: install
1518
cache:
1619
description: Enable Rust cache
1720
required: false
@@ -21,6 +24,16 @@ inputs:
2124
runs:
2225
using: composite
2326
steps:
27+
- name: Validate native dependency mode
28+
shell: bash
29+
env:
30+
DEPENDENCY_MODE: ${{ inputs.system-dependencies }}
31+
run: |
32+
case "$DEPENDENCY_MODE" in
33+
install|verify) ;;
34+
*) echo "::error::Unknown native dependency mode"; exit 1 ;;
35+
esac
36+
2437
- name: Read shared runner tool requirements
2538
shell: bash
2639
run: python3 .github/scripts/runner-image.py env
@@ -144,7 +157,7 @@ runs:
144157
# This composite is also used by hosted release, nightly and book jobs.
145158
# Keep their bootstrap path; only persistent runners require a prebaked image.
146159
- name: Install native dependencies on ephemeral GitHub-hosted Linux
147-
if: runner.os == 'Linux' && runner.environment == 'github-hosted'
160+
if: runner.os == 'Linux' && runner.environment == 'github-hosted' && inputs.system-dependencies == 'install'
148161
shell: bash
149162
run: |
150163
sudo apt-get update

‎.github/runner-requirements.json‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema": 1,
3-
"recipe_revision": "baf8849b900555d66714e0e1fcffdff669b9e404",
3+
"recipe_revision": "e49e8bc9977f5f961a76ba1d1f7673c72173679f",
44
"requirements": {
55
"schema": 2,
66
"contract_version": "1",
@@ -201,6 +201,28 @@
201201
"cmdline_tools": "19.0",
202202
"system_image": "system-images;android-35;default;x86_64",
203203
"abi": "x86_64"
204+
},
205+
"client_codegen": {
206+
"schema": 1,
207+
"versions": {
208+
"protobuf": "3.18.1",
209+
"grpc": "1.46.3",
210+
"grpc_java": "1.42.1"
211+
},
212+
"sources": [
213+
{
214+
"url": "https://github.com/protocolbuffers/protobuf/releases/download/v3.18.1/protobuf-cpp-3.18.1.tar.gz",
215+
"sha256": "6ee35eda3f79e49608d2ace8d866313fdec539d8bb14c6c54e8d2a16fa4e6780"
216+
},
217+
{
218+
"url": "https://github.com/grpc/grpc/archive/refs/tags/v1.46.3.tar.gz",
219+
"sha256": "d6cbf22cb5007af71b61c6be316a79397469c58c82a942552a62e708bce60964"
220+
},
221+
{
222+
"url": "https://github.com/grpc/grpc-java/archive/refs/tags/v1.42.1.tar.gz",
223+
"sha256": "33775a1ad05974bbba6ff97801cd9b326485b21fab91b08a4e1bc53e500e6326"
224+
}
225+
]
204226
}
205227
}
206228
}

‎.github/scripts/runner-image.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def export_environment(manifest, output):
7777

7878

7979
def select(manifest, kind, output, wait_seconds):
80-
fallback = ["self-hosted", "rust-ci" if kind == "rust" else "kotlin-ci"]
80+
fallback = ["self-hosted", {"rust": "rust-ci", "kotlin": "kotlin-ci", "npm": "npm-pr"}[kind]]
8181
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
8282
requested = event.get("pull_request")
8383
labels, changed = fallback, False
@@ -130,7 +130,7 @@ def main():
130130
parser.add_argument("--manifest", default=MANIFEST)
131131
parser.add_argument("--env", default=os.environ.get("GITHUB_ENV"))
132132
parser.add_argument("--output", default=os.environ.get("GITHUB_OUTPUT"))
133-
parser.add_argument("--kind", choices=["rust", "kotlin"])
133+
parser.add_argument("--kind", choices=["rust", "kotlin", "npm"])
134134
parser.add_argument("--wait-seconds", type=int, default=7200)
135135
args = parser.parse_args()
136136
manifest = read_manifest(args.manifest)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Execute the protected reusable workflow's guard against caller contexts."""
2+
import os
3+
from pathlib import Path
4+
import subprocess
5+
import unittest
6+
7+
ROOT = Path(__file__).resolve().parents[3]
8+
9+
10+
def release_guard():
11+
workflow = (ROOT / '.github/workflows/release-npm-build.yml').read_text()
12+
# The guard must run before checkout or any caller-controlled source.
13+
start = workflow.index(' - name: Reject untrusted release callers')
14+
end = workflow.index(' - uses: softwareforgood/', start)
15+
block = workflow[start:end]
16+
script = block.split(' run: |\n', 1)[1]
17+
return '\n'.join(line[10:] for line in script.splitlines() if line.strip())
18+
19+
20+
class ReleaseBoundaryTests(unittest.TestCase):
21+
def run_guard(self, event, ref, repository='dashpay/platform'):
22+
return subprocess.run(['bash', '-c', release_guard()], capture_output=True, text=True,
23+
env=dict(os.environ, GITHUB_REPOSITORY=repository,
24+
GITHUB_EVENT_NAME=event, GITHUB_REF=ref)).returncode
25+
26+
def test_should_allow_release_tags_and_protected_branch_dry_runs(self):
27+
for event, ref in [('release', 'refs/tags/v4.2.0-beta.5'),
28+
('workflow_dispatch', 'refs/tags/v4.2.0-beta.5'),
29+
('workflow_dispatch', 'refs/heads/v4.2-dev')]:
30+
with self.subTest(event=event, ref=ref):
31+
self.assertEqual(self.run_guard(event, ref), 0)
32+
33+
def test_should_reject_prs_forks_and_unprotected_dispatches(self):
34+
for event, ref in [('pull_request', 'refs/pull/5068/merge'),
35+
('pull_request_target', 'refs/heads/v4.2-dev'),
36+
('workflow_dispatch', 'refs/heads/attacker'),
37+
('push', 'refs/heads/v4.2-dev'),
38+
('release', 'refs/heads/v4.2-dev')]:
39+
with self.subTest(event=event, ref=ref):
40+
self.assertNotEqual(self.run_guard(event, ref), 0)
41+
self.assertNotEqual(self.run_guard('release', 'refs/tags/v4.2.0', 'unknown/platform'), 0)

‎.github/scripts/tests/test_runner_image.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ def setUp(self):
4040
"event": "pull_request_target", "conclusion": "success"},
4141
}
4242

43-
def select(self, event=None):
43+
def select(self, event=None, kind="rust"):
4444
self.event.write_text(json.dumps(event if event is not None else {"pull_request": self.pr}))
4545
with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(self.event)}), \
4646
patch.object(runner, "api", side_effect=lambda path: self.responses[path]):
47-
runner.select(self.manifest, "rust", self.output, 0)
47+
runner.select(self.manifest, kind, self.output, 0)
4848
return dict(line.split("=", 1) for line in self.output.read_text().splitlines())
4949

5050
def test_non_pr_and_unchanged_pr_use_existing_pool(self):
@@ -58,6 +58,11 @@ def test_exact_candidate_includes_head_digest_and_kind(self):
5858
self.assertEqual(labels[-1], f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-rust")
5959
self.assertEqual(output["image_changed"], "true")
6060

61+
def test_npm_candidates_and_ordinary_pool_have_distinct_labels(self):
62+
labels = json.loads(self.select(kind="npm")["labels"])
63+
self.assertEqual(labels[-1], f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-npm")
64+
self.assertEqual(json.loads(self.select({}, kind="npm")["labels"]), ["self-hosted", "npm-pr"])
65+
6166
def test_new_head_or_closed_pr_rejects_stale_run(self):
6267
event = {"pull_request": copy.deepcopy(self.pr)}
6368
self.pr["head"]["sha"] = "e" * 40

0 commit comments

Comments
 (0)