Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/NPM_RUNNER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# NPM release runners

NPM release compilation uses `[self-hosted, Linux, X64, npm-build]` in the
restricted `platform-npm-releases` runner group. Publishing
continues on GitHub-hosted Ubuntu with OIDC; the builder receives no publishing
credentials. The `npm-release-build` action is shared by releases and image
validation so both compile and pack with the same setup.

## Image contract

`.github/runner-requirements.json` pins the complete Linux image requirements and
recipe commit. The job runs `ci-image-contract verify` before compiling. The
runner must have `/opt/client-codegen` matching `packages/dapi-grpc/codegen.json`.
Its protobuf 3.18.1 compiler is intentionally separate from Rust's protoc 32.0.
TypeScript generation comes from the workspace's pinned `ts-protoc-gen` dependency.

Image-owned native dependencies are verified, never installed using sudo. Rust,
Node and the pinned WASM tools use writable runner/user locations. Cargo targets
remain outside the checkout; each release starts with a fresh workspace. The
runner needs no Docker CLI/socket or KVM device.

## Provisioning and promotion

Use the reviewed `dashpay/dash-selfhosted-image` recipe and a tested immutable
image digest, not a moving tag. Register dedicated release capacity with `npm-build` only after
the NPM validation workflow succeeds on that image. Drain old registrations before
replacement; retain their image/configuration for rollback. Old release tags
still contain their original workflows and do not automatically gain this fix.

Requirements-changing PRs select a candidate label bound to the complete PR head
and image digest. The image controller must support the `npm` job kind and
`.github/workflows/npm-runner-validation.yml`. Manifests requesting native client
generation require successful Rust, Kotlin and NPM candidate jobs for promotion;
skipped fork jobs do not qualify. Existing same-repository/trusted-fork guards
remain in effect.

The trusted `runner-image-candidate.yml` bootstrap, controller and Rust/Kotlin
candidate routing must be installed on each consuming branch before candidate
promotion can work. Platform PRs #4702 and #4912 establish those pieces; reconcile
their requirements/selector files with this NPM extension when landing them. In
particular, update both the bootstrap's reusable-workflow SHA and its
`control_revision` to a reviewed image-repository revision supporting
`client_codegen` and `npm`. Merely changing `recipe_revision` is insufficient.
The default `v4.2-dev` and `v4.3-dev` branches must each use an explicit compatible
manifest; this change does not alter an existing release tag or deploy a runner.

## Verification

`npm-runner-validation.yml` runs the real release build and packing action, DAPI
unit tests, and a byte-for-byte check that packed Node/web clients match the
freshly generated files. It uploads tarballs but never publishes them.
`test-client-codegen.yml` also builds the native compilers on hosted Linux/macOS,
checks committed generated output, tests failure recovery and validates packing.

Local setup and generator test commands are in `packages/dapi-grpc/README.md`.

## Separate PR and release state

The `platform-npm-releases` organization runner group must select only the
`dashpay/platform` repository and restrict execution to:

```text
dashpay/platform/.github/workflows/release-npm-build.yml@refs/heads/v4.2-dev
```

Protect that branch with the normal maintainer review policy. `release.yml`
invokes that protected reusable workflow; the reusable workflow rejects PR
callers and arbitrary branch dispatches before checkout. A PR cannot select the
release group by changing its own workflow to request the `npm-build` label.
The group-level selected-workflow restriction is a required operator setting,
not something a repository workflow can grant itself.

Use separate runner containers/VMs and separate registration, workspace, HOME,
Cargo registry and target-cache storage for PR and release pools. Do not mount
the same cache volumes into both pools. Release caches can persist between
releases; no PR may write them. Ordinary PR validation uses `npm-pr`; image
candidates continue to use fresh one-job registrations and volumes. Do not add
`npm-pr`, `rust-ci` or `kotlin-ci` to the release registration.

For another maintained branch, create its reviewed protected reusable-workflow
ref and corresponding group policy explicitly. Do not wildcard the workflow
restriction or allow PR refs. Validate the policy by attempting a PR job that
requests the release group: it must be rejected, while a permitted release dry
run succeeds. The workflow guard is defense in depth; enabling the release
pool without its group restriction does not establish this boundary.
2 changes: 2 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ self-hosted-runner:
labels:
- rust-ci
- kotlin-ci
- npm-build
- npm-pr
121 changes: 121 additions & 0 deletions .github/actions/npm-release-build/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: Build release NPM packages
description: Compile and pack on the provisioned unprivileged Linux image
inputs:
cache-name:
description: Persistent Cargo target cache name
default: release-npm-target
runs:
using: composite
steps:
- name: Verify provisioned runner dependencies
shell: bash
run: |
set -euo pipefail

ci-image-contract verify .github/runner-requirements.json
python3 packages/dapi-grpc/scripts/setup-codegen.py
for pkg in build-essential cmake curl jq libgmp-dev libssl-dev pkg-config python3 unzip zip; do
dpkg-query -W -f='${Status}' "$pkg" | grep -Fx 'install ok installed'
done
test -x "$HOME/.cargo/bin/rustup"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Setup Rust
uses: ./.github/actions/rust
with:
target: wasm32-unknown-unknown
# The runner persists the Cargo registry between runs.
cache: 'false'
system-dependencies: verify

# The Rust action above reuses a protoc left in ~/.local by earlier jobs.
# Override it with a fresh, checksum-verified copy for the release build;
# prost-build reads PROTOC first.
- name: Install protoc v32.0
env:
PROTOC_SHA256: 7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88
shell: bash
run: |
set -euo pipefail
PROTOC_DIR="$RUNNER_TEMP/protoc-32.0"
curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \
https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip
echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | sha256sum -c -
rm -rf "$PROTOC_DIR"
unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR"
echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV"
echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH"
"$PROTOC_DIR/bin/protoc" --version

- name: Prepare release Cargo target cache
uses: ./.github/actions/release-cargo-target-cache
with:
name: ${{ inputs.cache-name }}

- name: Setup Node.JS
uses: ./.github/actions/nodejs

- name: Install Cargo binstall
uses: cargo-bins/cargo-binstall@v1.3.1

# Always reinstalled, never trusted from an earlier job: a binary left on
# this persistent runner can print the pinned version and still be
# something else.
- name: Install wasm-bindgen-cli
shell: bash
run: |
set -euo pipefail
cargo binstall wasm-bindgen-cli@0.2.108 --no-confirm --force
wasm-bindgen --version | grep -Fxq 'wasm-bindgen 0.2.108'

- name: Install wasm-pack
shell: bash
run: |
set -euo pipefail
cargo binstall wasm-pack@0.15.0 --no-confirm --force
wasm-pack --version | grep -Fxq 'wasm-pack 0.15.0'

# Fresh, checksum-verified copy in this job's temp directory rather than
# one left in ~/.local by an earlier job.
- name: Install Binaryen
env:
BINARYEN_SHA256: c90e0e295e8f8484ba5b47da92f26e5d1d18db6cd2fcc0c5cc265a5a73609f17
shell: bash
run: |
set -euo pipefail
ARCHIVE="$RUNNER_TEMP/binaryen-version_121-x86_64-linux.tar.gz"
curl -fsSL -o "$ARCHIVE" \
https://github.com/WebAssembly/binaryen/releases/download/version_121/binaryen-version_121-x86_64-linux.tar.gz
echo "$BINARYEN_SHA256 $ARCHIVE" | sha256sum -c -
rm -rf "$RUNNER_TEMP/binaryen-version_121"
tar -xzf "$ARCHIVE" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/binaryen-version_121/bin" >> "$GITHUB_PATH"

- name: Build packages
shell: bash
run: yarn build
env:
CARGO_BUILD_PROFILE: release

- name: Ignore only already cached artifacts
shell: bash
run: |
find . -name '.gitignore' -exec rm -f {} +
{
echo ".yarn"
echo "target"
echo "node_modules"
echo ".nyc_output"
echo ".idea"
echo ".ultra.cache.json"
echo "db/*"
echo "npm-packages"
} >> .gitignore

- name: Pack public workspaces
shell: bash
run: |
mkdir -p npm-packages
yarn workspaces foreach --all --no-private --parallel pack \
--out "$GITHUB_WORKSPACE/npm-packages/%s-%v.tgz"
test -n "$(find npm-packages -maxdepth 1 -type f -name '*.tgz' -print -quit)"
15 changes: 14 additions & 1 deletion .github/actions/rust/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ inputs:
components:
description: List of additional Rust toolchain components to install
required: false
system-dependencies:
description: Install native packages on hosted runners, or verify a provisioned image
default: install
cache:
description: Enable Rust cache
required: false
Expand All @@ -21,6 +24,16 @@ inputs:
runs:
using: composite
steps:
- name: Validate native dependency mode
shell: bash
env:
DEPENDENCY_MODE: ${{ inputs.system-dependencies }}
run: |
case "$DEPENDENCY_MODE" in
install|verify) ;;
*) echo "::error::Unknown native dependency mode"; exit 1 ;;
esac

- name: Read shared runner tool requirements
shell: bash
run: python3 .github/scripts/runner-image.py env
Expand Down Expand Up @@ -144,7 +157,7 @@ runs:
# This composite is also used by hosted release, nightly and book jobs.
# Keep their bootstrap path; only persistent runners require a prebaked image.
- name: Install native dependencies on ephemeral GitHub-hosted Linux
if: runner.os == 'Linux' && runner.environment == 'github-hosted'
if: runner.os == 'Linux' && runner.environment == 'github-hosted' && inputs.system-dependencies == 'install'
shell: bash
run: |
sudo apt-get update
Expand Down
24 changes: 23 additions & 1 deletion .github/runner-requirements.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema": 1,
"recipe_revision": "baf8849b900555d66714e0e1fcffdff669b9e404",
"recipe_revision": "e49e8bc9977f5f961a76ba1d1f7673c72173679f",
"requirements": {
"schema": 2,
"contract_version": "1",
Expand Down Expand Up @@ -201,6 +201,28 @@
"cmdline_tools": "19.0",
"system_image": "system-images;android-35;default;x86_64",
"abi": "x86_64"
},
"client_codegen": {
"schema": 1,
"versions": {
"protobuf": "3.18.1",
"grpc": "1.46.3",
"grpc_java": "1.42.1"
},
"sources": [
{
"url": "https://github.com/protocolbuffers/protobuf/releases/download/v3.18.1/protobuf-cpp-3.18.1.tar.gz",
"sha256": "6ee35eda3f79e49608d2ace8d866313fdec539d8bb14c6c54e8d2a16fa4e6780"
},
{
"url": "https://github.com/grpc/grpc/archive/refs/tags/v1.46.3.tar.gz",
"sha256": "d6cbf22cb5007af71b61c6be316a79397469c58c82a942552a62e708bce60964"
},
{
"url": "https://github.com/grpc/grpc-java/archive/refs/tags/v1.42.1.tar.gz",
"sha256": "33775a1ad05974bbba6ff97801cd9b326485b21fab91b08a4e1bc53e500e6326"
}
]
}
}
}
4 changes: 2 additions & 2 deletions .github/scripts/runner-image.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def export_environment(manifest, output):


def select(manifest, kind, output, wait_seconds):
fallback = ["self-hosted", "rust-ci" if kind == "rust" else "kotlin-ci"]
fallback = ["self-hosted", {"rust": "rust-ci", "kotlin": "kotlin-ci", "npm": "npm-pr"}[kind]]
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
requested = event.get("pull_request")
labels, changed = fallback, False
Expand Down Expand Up @@ -130,7 +130,7 @@ def main():
parser.add_argument("--manifest", default=MANIFEST)
parser.add_argument("--env", default=os.environ.get("GITHUB_ENV"))
parser.add_argument("--output", default=os.environ.get("GITHUB_OUTPUT"))
parser.add_argument("--kind", choices=["rust", "kotlin"])
parser.add_argument("--kind", choices=["rust", "kotlin", "npm"])
parser.add_argument("--wait-seconds", type=int, default=7200)
args = parser.parse_args()
manifest = read_manifest(args.manifest)
Expand Down
41 changes: 41 additions & 0 deletions .github/scripts/tests/test_npm_release_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Execute the protected reusable workflow's guard against caller contexts."""
import os
from pathlib import Path
import subprocess
import unittest

ROOT = Path(__file__).resolve().parents[3]


def release_guard():
workflow = (ROOT / '.github/workflows/release-npm-build.yml').read_text()
# The guard must run before checkout or any caller-controlled source.
start = workflow.index(' - name: Reject untrusted release callers')
end = workflow.index(' - uses: softwareforgood/', start)
block = workflow[start:end]
script = block.split(' run: |\n', 1)[1]
return '\n'.join(line[10:] for line in script.splitlines() if line.strip())


class ReleaseBoundaryTests(unittest.TestCase):
def run_guard(self, event, ref, repository='dashpay/platform'):
return subprocess.run(['bash', '-c', release_guard()], capture_output=True, text=True,
env=dict(os.environ, GITHUB_REPOSITORY=repository,
GITHUB_EVENT_NAME=event, GITHUB_REF=ref)).returncode

def test_should_allow_release_tags_and_protected_branch_dry_runs(self):
for event, ref in [('release', 'refs/tags/v4.2.0-beta.5'),
('workflow_dispatch', 'refs/tags/v4.2.0-beta.5'),
('workflow_dispatch', 'refs/heads/v4.2-dev')]:
with self.subTest(event=event, ref=ref):
self.assertEqual(self.run_guard(event, ref), 0)

def test_should_reject_prs_forks_and_unprotected_dispatches(self):
for event, ref in [('pull_request', 'refs/pull/5068/merge'),
('pull_request_target', 'refs/heads/v4.2-dev'),
('workflow_dispatch', 'refs/heads/attacker'),
('push', 'refs/heads/v4.2-dev'),
('release', 'refs/heads/v4.2-dev')]:
with self.subTest(event=event, ref=ref):
self.assertNotEqual(self.run_guard(event, ref), 0)
self.assertNotEqual(self.run_guard('release', 'refs/tags/v4.2.0', 'unknown/platform'), 0)
9 changes: 7 additions & 2 deletions .github/scripts/tests/test_runner_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ def setUp(self):
"event": "pull_request_target", "conclusion": "success"},
}

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

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

def test_npm_candidates_and_ordinary_pool_have_distinct_labels(self):
labels = json.loads(self.select(kind="npm")["labels"])
self.assertEqual(labels[-1], f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-npm")
self.assertEqual(json.loads(self.select({}, kind="npm")["labels"]), ["self-hosted", "npm-pr"])

def test_new_head_or_closed_pr_rejects_stale_run(self):
event = {"pull_request": copy.deepcopy(self.pr)}
self.pr["head"]["sha"] = "e" * 40
Expand Down
Loading
Loading