mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
fix: assemble verified host runner assets before release
Reuse release verification producers for the supported native hosts. Validate source-bound data before publication and probe the installed Codex command in the existing offline consumer. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5fa4abaead
commit
f8e2bee66e
10 files changed
+1061
-19
No files matched your search
@@ -7,18 +7,28 @@ on:
|
||||
description: Commit SHA, branch, or tag to verify
|
||||
required: true
|
||||
type: string
|
||||
runner_assets_only:
|
||||
description: Build only the release daemon/provider-pack data after the caller's source gate
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
runner_assets_artifact:
|
||||
description: Run-bound verified release daemon/provider-pack data
|
||||
value: ${{ jobs.runner_release_assemble.outputs.artifact_name }}
|
||||
|
||||
# Caller-provided refs may name unmerged PR code. AWS is eligible only when
|
||||
# the caller runs on canonical master and verifies that event's exact SHA.
|
||||
# The organization group also restricts these workflow files to master.
|
||||
jobs:
|
||||
runner_chaos_evals:
|
||||
if: ${{ !inputs.runner_assets_only }}
|
||||
name: Pre-release Runner chaos evals
|
||||
uses: ./.github/workflows/runner-chaos-evals.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
typecheck:
|
||||
if: ${{ !inputs.runner_assets_only }}
|
||||
name: Typecheck
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -97,6 +107,7 @@ jobs:
|
||||
run: pnpm -r typecheck
|
||||
|
||||
general_tests:
|
||||
if: ${{ !inputs.runner_assets_only }}
|
||||
name: General tests (${{ matrix.group_label }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -204,6 +215,7 @@ jobs:
|
||||
fi
|
||||
|
||||
serialized_tests:
|
||||
if: ${{ !inputs.runner_assets_only }}
|
||||
name: Serialized tests (${{ matrix.shard_label }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -253,6 +265,7 @@ jobs:
|
||||
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
|
||||
|
||||
runner_workflow_evals:
|
||||
if: ${{ !inputs.runner_assets_only }}
|
||||
name: Runner workflow eval scorer contract
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
@@ -283,6 +296,7 @@ jobs:
|
||||
run: pnpm test:runner-workflow-evals
|
||||
|
||||
verify_paperclip_runner:
|
||||
if: ${{ !inputs.runner_assets_only }}
|
||||
name: Verify Paperclip Runner (${{ matrix.lane }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -396,6 +410,7 @@ jobs:
|
||||
run: pnpm --filter @paperclipai/paperclip-runner build:rust
|
||||
|
||||
build:
|
||||
if: ${{ !inputs.runner_assets_only }}
|
||||
name: Build
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -424,3 +439,178 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
# Only the missing release producers. Source verification stays with the
|
||||
# caller; these unprivileged jobs neither publish nor receive provider secrets.
|
||||
runner_release_binaries:
|
||||
needs: runner_release_request
|
||||
if: inputs.runner_assets_only
|
||||
name: Release daemon (${{ matrix.target }})
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: darwin-arm64
|
||||
runner: macos-15
|
||||
- target: darwin-x64
|
||||
runner: macos-15-intel
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Build the exact native release daemon
|
||||
working-directory: packages/paperclip-runner
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
rustup show
|
||||
cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd
|
||||
- name: Record native architecture, checksum and executable metadata
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
node scripts/release-runner-artifacts.mjs record-binary "$SOURCE_SHA" "$TARGET" \
|
||||
"$GITHUB_WORKSPACE/packages/paperclip-runner/runner/target/release/paperclip-runnerd" \
|
||||
"$RUNNER_TEMP/runner-release/$TARGET"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: runner-release-${{ inputs.ref }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.target }}
|
||||
path: ${{ runner.temp }}/runner-release/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
runner_release_pack:
|
||||
needs: runner_release_request
|
||||
if: inputs.runner_assets_only
|
||||
name: Actual release image Linux daemon and provider-pack identity
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
- name: Extract the actual exact-source image provider-pack
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
mkdir -p "$RUNNER_TEMP/runner-release-input"
|
||||
owner="paperclip-release-pack-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
|
||||
trap 'docker rm -f "$owner" >/dev/null 2>&1 || true' EXIT
|
||||
# Canary images publish after npm. Build the existing image target
|
||||
# without registry credentials and consume its actual source bytes.
|
||||
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
|
||||
image="$owner"
|
||||
docker buildx build --platform linux/amd64 --target runner-provider-pack \
|
||||
--build-arg "PAPERCLIP_BUILD_COMMIT=$SOURCE_SHA" \
|
||||
--label "org.opencontainers.image.revision=$SOURCE_SHA" \
|
||||
--cache-from type=registry,ref=ghcr.io/paperclipai/paperclip:buildcache-amd64 \
|
||||
--tag "$image" --load .
|
||||
pack_path=/provider-pack/provider-pack.json
|
||||
docker image inspect "$image" > "$RUNNER_TEMP/runner-release-input/image.json"
|
||||
daemon_path=/app/server/dist/vendor/paperclip-runner/bin/paperclip-runnerd
|
||||
docker create --name "$owner" --network none --user 1000:1000 --read-only \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
--entrypoint "$daemon_path" "$image" --build-metadata >/dev/null
|
||||
docker cp "$owner:$pack_path" "$RUNNER_TEMP/runner-release-input/provider-pack.json"
|
||||
docker cp "$owner:$daemon_path" "$RUNNER_TEMP/runner-release-input/paperclip-runnerd"
|
||||
# Execute only inside the source image. Its libc may be newer than
|
||||
# ubuntu-latest; host execution would not prove this image artifact.
|
||||
timeout 30s docker start --attach "$owner" > "$RUNNER_TEMP/runner-release-input/daemon-metadata.json"
|
||||
test "$(docker inspect --format '{{.State.ExitCode}}' "$owner")" = 0
|
||||
node scripts/release-runner-artifacts.mjs record-image-binary "$SOURCE_SHA" \
|
||||
"$RUNNER_TEMP/runner-release-input/paperclip-runnerd" "$RUNNER_TEMP/runner-release-input/daemon-metadata.json" \
|
||||
"$RUNNER_TEMP/runner-release-input/image.json" "$RUNNER_TEMP/runner-release/linux-x64"
|
||||
node scripts/release-runner-artifacts.mjs record-pack "$SOURCE_SHA" \
|
||||
"$RUNNER_TEMP/runner-release-input/provider-pack.json" "$RUNNER_TEMP/runner-release-input/image.json" \
|
||||
"$RUNNER_TEMP/runner-release/provider-pack"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: runner-release-${{ inputs.ref }}-${{ github.run_id }}-${{ github.run_attempt }}-provider-pack
|
||||
path: ${{ runner.temp }}/runner-release/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
runner_release_assemble:
|
||||
if: inputs.runner_assets_only
|
||||
name: Assemble verified release runner data
|
||||
needs: [runner_release_binaries, runner_release_pack]
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
artifact_name: ${{ steps.identity.outputs.artifact_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: runner-release-${{ inputs.ref }}-${{ github.run_id }}-${{ github.run_attempt }}-*
|
||||
path: ${{ runner.temp }}/runner-release-inputs
|
||||
merge-multiple: true
|
||||
- name: Run the sole source-aware assembler and bound its data envelope
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
node scripts/release-runner-artifacts.mjs assemble "$SOURCE_SHA" \
|
||||
"$RUNNER_TEMP/runner-release-inputs" "$RUNNER_TEMP/runner-release-assembled"
|
||||
- name: Record run-bound assembled artifact
|
||||
id: identity
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
run: echo "artifact_name=runner-release-assembled-$SOURCE_SHA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ${{ steps.identity.outputs.artifact_name }}
|
||||
path: |
|
||||
${{ runner.temp }}/runner-release-assembled/runner-release-assets.tar.gz
|
||||
${{ runner.temp }}/runner-release-assembled/runner-release-assets.tar.gz.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
runner_release_request:
|
||||
if: inputs.runner_assets_only
|
||||
name: Validate immutable runner producer inputs
|
||||
permissions: {}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Reject mutable sources before any producer checkout
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]
|
||||
@@ -337,8 +337,11 @@ jobs:
|
||||
run: node scripts/cloud-source-verification.mjs "$SOURCE_SHA"
|
||||
|
||||
publish_canary:
|
||||
if: github.event_name == 'push'
|
||||
needs: verify_canary
|
||||
if: >-
|
||||
!cancelled() && github.event_name == 'push' && needs.verify_canary.result == 'success' &&
|
||||
needs.pin_runner_release_source.result == 'success' &&
|
||||
(needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success')
|
||||
needs: [verify_canary, pin_runner_release_source, release_runner_assets]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
environment: npm-canary
|
||||
@@ -350,10 +353,14 @@ jobs:
|
||||
# For the explicit docker.yml dispatch below.
|
||||
actions: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: source
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
@@ -366,6 +373,33 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: source/pnpm-lock.yaml
|
||||
|
||||
- name: Checkout trusted release transfer tools
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: trusted
|
||||
persist-credentials: false
|
||||
- name: Download this run's verified runner data
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }}
|
||||
path: ${{ runner.temp }}/runner-release-archive
|
||||
- name: Validate runner data against the selected source before publishing
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \
|
||||
"$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source"
|
||||
echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
@@ -587,11 +621,13 @@ jobs:
|
||||
artifact_name: nightly-release-smoke
|
||||
|
||||
publish_nightly:
|
||||
needs: [select_nightly, smoke_nightly]
|
||||
needs: [select_nightly, smoke_nightly, pin_runner_release_source, release_runner_assets]
|
||||
# Publish when smoke passed, or when smoke was deliberately skipped by a
|
||||
# dry-run dispatch (the publish itself is a dry-run in that case).
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.pin_runner_release_source.result == 'success' &&
|
||||
(needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success') &&
|
||||
needs.select_nightly.outputs.proceed == 'true' &&
|
||||
(needs.smoke_nightly.result == 'success' ||
|
||||
(needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run))
|
||||
@@ -611,10 +647,14 @@ jobs:
|
||||
id-token: write
|
||||
actions: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: source
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: source
|
||||
ref: ${{ needs.select_nightly.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -628,6 +668,33 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: source/pnpm-lock.yaml
|
||||
|
||||
- name: Checkout trusted release transfer tools
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: trusted
|
||||
persist-credentials: false
|
||||
- name: Download this run's verified runner data
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }}
|
||||
path: ${{ runner.temp }}/runner-release-archive
|
||||
- name: Validate runner data against the selected source before publishing
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \
|
||||
"$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source"
|
||||
echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
@@ -855,9 +922,11 @@ jobs:
|
||||
ref: ${{ needs.select_beta.outputs.sha }}
|
||||
|
||||
publish_beta:
|
||||
needs: [select_beta, verify_beta_candidate]
|
||||
needs: [select_beta, verify_beta_candidate, pin_runner_release_source, release_runner_assets]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.pin_runner_release_source.result == 'success' &&
|
||||
(needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success') &&
|
||||
needs.select_beta.result == 'success' &&
|
||||
(needs.verify_beta_candidate.result == 'success' ||
|
||||
(needs.verify_beta_candidate.result == 'skipped' && needs.select_beta.outputs.mode == 'promote'))
|
||||
@@ -877,10 +946,14 @@ jobs:
|
||||
outputs:
|
||||
beta_version: ${{ steps.result.outputs.beta_version }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: source
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: source
|
||||
ref: ${{ needs.select_beta.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -894,6 +967,33 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: source/pnpm-lock.yaml
|
||||
|
||||
- name: Checkout trusted release transfer tools
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: trusted
|
||||
persist-credentials: false
|
||||
- name: Download this run's verified runner data
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }}
|
||||
path: ${{ runner.temp }}/runner-release-archive
|
||||
- name: Validate runner data against the selected source before publishing
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \
|
||||
"$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source"
|
||||
echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
@@ -1240,17 +1340,24 @@ jobs:
|
||||
ref: ${{ needs.preflight_stable.outputs.sha }}
|
||||
|
||||
preview_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run
|
||||
needs: [preflight_stable, verify_stable]
|
||||
if: >-
|
||||
!cancelled() && github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run &&
|
||||
needs.verify_stable.result == 'success' && needs.pin_runner_release_source.result == 'success' &&
|
||||
(needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success')
|
||||
needs: [preflight_stable, verify_stable, pin_runner_release_source, release_runner_assets]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: source
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.preflight_stable.outputs.sha }}
|
||||
|
||||
@@ -1264,6 +1371,33 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: source/pnpm-lock.yaml
|
||||
|
||||
- name: Checkout trusted release transfer tools
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: trusted
|
||||
persist-credentials: false
|
||||
- name: Download this run's verified runner data
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }}
|
||||
path: ${{ runner.temp }}/runner-release-archive
|
||||
- name: Validate runner data against the selected source before publishing
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \
|
||||
"$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source"
|
||||
echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
@@ -1282,8 +1416,11 @@ jobs:
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
publish_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run
|
||||
needs: [preflight_stable, verify_stable]
|
||||
if: >-
|
||||
!cancelled() && github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run &&
|
||||
needs.verify_stable.result == 'success' && needs.pin_runner_release_source.result == 'success' &&
|
||||
(needs.pin_runner_release_source.outputs.required != 'true' || needs.release_runner_assets.result == 'success')
|
||||
needs: [preflight_stable, verify_stable, pin_runner_release_source, release_runner_assets]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
environment: npm-stable
|
||||
@@ -1294,10 +1431,14 @@ jobs:
|
||||
outputs:
|
||||
stable_version: ${{ steps.tag.outputs.version }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: source
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.preflight_stable.outputs.sha }}
|
||||
|
||||
@@ -1311,6 +1452,33 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: source/pnpm-lock.yaml
|
||||
|
||||
- name: Checkout trusted release transfer tools
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: trusted
|
||||
persist-credentials: false
|
||||
- name: Download this run's verified runner data
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ${{ needs.release_runner_assets.outputs.runner_assets_artifact }}
|
||||
path: ${{ runner.temp }}/runner-release-archive
|
||||
- name: Validate runner data against the selected source before publishing
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.pin_runner_release_source.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
node "$GITHUB_WORKSPACE/trusted/scripts/release-runner-artifacts.mjs" unpack "$SOURCE_SHA" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz" \
|
||||
"$RUNNER_TEMP/runner-release-archive/runner-release-assets.tar.gz.sha256" \
|
||||
"$RUNNER_TEMP/runner-release-assets" "$GITHUB_WORKSPACE/source"
|
||||
echo "PAPERCLIP_RELEASE_RUNNER_ASSETS=$RUNNER_TEMP/runner-release-assets" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
@@ -1477,3 +1645,63 @@ jobs:
|
||||
echo "- Open and merge the PR (a human opens it so CI runs):"
|
||||
echo " https://github.com/${GITHUB_REPOSITORY}/compare/master...${branch}?expand=1"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Resolve one applicable release channel after its existing gate. Optional
|
||||
# channels are deliberately skipped, so use explicit results, never an
|
||||
# implicit all-needs-success dependency that would suppress promotions.
|
||||
pin_runner_release_source:
|
||||
needs: [verify_canary, select_nightly, select_beta, verify_beta_candidate, preflight_stable, verify_stable]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
((github.event_name == 'push' && needs.verify_canary.result == 'success') ||
|
||||
(needs.select_nightly.result == 'success' && needs.select_nightly.outputs.proceed == 'true') ||
|
||||
(needs.select_beta.result == 'success' &&
|
||||
(needs.verify_beta_candidate.result == 'success' || needs.select_beta.outputs.mode == 'promote')) ||
|
||||
(needs.preflight_stable.result == 'success' && needs.verify_stable.result == 'success'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
source_sha: ${{ steps.pin.outputs.source_sha }}
|
||||
required: ${{ steps.pin.outputs.required }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Pin the gated source and identify its release assembly contract
|
||||
id: pin
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CANARY_SHA: ${{ github.sha }}
|
||||
NIGHTLY_SHA: ${{ needs.select_nightly.outputs.sha }}
|
||||
BETA_SHA: ${{ needs.select_beta.outputs.sha }}
|
||||
STABLE_SHA: ${{ needs.preflight_stable.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$EVENT_NAME" == push ]]; then source_sha="$CANARY_SHA";
|
||||
elif [[ -n "$NIGHTLY_SHA" ]]; then source_sha="$NIGHTLY_SHA";
|
||||
elif [[ -n "$BETA_SHA" ]]; then source_sha="$BETA_SHA";
|
||||
else source_sha="$STABLE_SHA"; fi
|
||||
[[ "$source_sha" =~ ^[a-f0-9]{40}$ ]]
|
||||
git cat-file -e "$source_sha^{commit}"
|
||||
required=false
|
||||
# Promoted older sources retain their original contract. New sources
|
||||
# opt in with the checked-in hook and may never publish partial assets.
|
||||
if git show "$source_sha:scripts/release.sh" | grep -F 'PAPERCLIP_RELEASE_RUNNER_ASSETS' >/dev/null; then
|
||||
git cat-file -e "$source_sha:scripts/release-runner-artifacts.mjs"
|
||||
required=true
|
||||
fi
|
||||
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "required=$required" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release_runner_assets:
|
||||
needs: pin_runner_release_source
|
||||
if: needs.pin_runner_release_source.outputs.required == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-verify.yml
|
||||
with:
|
||||
ref: ${{ needs.pin_runner_release_source.outputs.source_sha }}
|
||||
runner_assets_only: true
|
||||
@@ -7,7 +7,14 @@ const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(
|
||||
: JSON.stringify(value);
|
||||
|
||||
/** Release assembly must reject a self-consistent pack built for another release. */
|
||||
export function verifyReleaseProviderPack(identity, sourceRevision) {
|
||||
export function verifyReleaseProviderPack(identity, sourceRevision, sourceProfiles = profiles, sourceDistributions = distributions) {
|
||||
if (sourceProfiles?.schema !== "paperclip.acpx-profiles.v1" || typeof sourceProfiles.acpxVersion !== "string" || !sourceProfiles.acpxVersion
|
||||
|| ["grok", "claude", "codex", "cursor"].some(agent => !/^sha256:[a-f0-9]{64}$/.test(sourceProfiles?.profiles?.[agent]?.commandDigest ?? ""))
|
||||
|| typeof sourceProfiles?.profiles?.cursor?.agentServerVersion !== "string" || !sourceProfiles.profiles.cursor.agentServerVersion
|
||||
|| sourceDistributions?.schema !== "paperclip.cursor_distribution.v1"
|
||||
|| !/^[a-f0-9]{64}$/.test(sourceDistributions?.platforms?.["linux-x64"]?.closureSha256 ?? "")) {
|
||||
throw new Error("Invalid release qualification snapshots");
|
||||
}
|
||||
const payload = identity?.payload;
|
||||
if (identity?.schema !== "paperclip-runner/remote-provider-pack/v1" || !payload
|
||||
|| payload.target?.platform !== "linux" || payload.target?.architecture !== "x64"
|
||||
@@ -15,14 +22,14 @@ export function verifyReleaseProviderPack(identity, sourceRevision) {
|
||||
if (identity.digest !== `sha256:${createHash("sha256").update(canonical(payload)).digest("hex")}`) {
|
||||
throw new Error("Remote provider-pack payload digest mismatch");
|
||||
}
|
||||
if (payload.pins?.acpx !== profiles.acpxVersion
|
||||
|| ["grok", "claude", "codex"].some(agent => payload.acpxProfileDigests?.[agent] !== profiles.profiles[agent].commandDigest)) {
|
||||
if (payload.pins?.acpx !== sourceProfiles.acpxVersion
|
||||
|| ["grok", "claude", "codex"].some(agent => payload.acpxProfileDigests?.[agent] !== sourceProfiles.profiles[agent].commandDigest)) {
|
||||
throw new Error("Remote provider-pack ACPX profiles do not match this release");
|
||||
}
|
||||
const current = profiles.profiles.cursor;
|
||||
const current = sourceProfiles.profiles.cursor;
|
||||
for (const cursor of [payload.providers?.cursor, ...(Object.hasOwn(payload.candidateProviders ?? {}, "cursor") ? [payload.candidateProviders.cursor] : [])]) {
|
||||
if (!cursor || cursor.version !== current.agentServerVersion || cursor.profileDigest !== current.commandDigest
|
||||
|| cursor.closureDigest !== `sha256:${distributions.platforms["linux-x64"].closureSha256}`
|
||||
|| cursor.closureDigest !== `sha256:${sourceDistributions.platforms["linux-x64"].closureSha256}`
|
||||
|| cursor.qualification !== "qualified" || cursor.path !== "provider-assets/cursor/linux-x64"
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(cursor.sha256 ?? "")) {
|
||||
throw new Error("Remote provider-pack Cursor identity does not match this release");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -9,6 +9,8 @@ import test from "node:test";
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import distributions from "../cursor-distributions.json" with { type: "json" };
|
||||
import { verifyReleaseProviderPack } from "./release-provider-pack.mjs";
|
||||
import { makeRunnerReleaseArchive, readRunnerReleaseArchive, verifyReleaseDaemonMetadata, verifyRunnerReleaseData } from "../../../scripts/release-runner-artifacts.mjs";
|
||||
import { gzipSync, gunzipSync } from "node:zlib";
|
||||
|
||||
const revision = "a".repeat(40);
|
||||
const digest = "sha256:" + "b".repeat(64);
|
||||
@@ -26,6 +28,162 @@ function fixture() {
|
||||
function seal(payload) { return { schema: "paperclip-runner/remote-provider-pack/v1", payload,
|
||||
digest: `sha256:${createHash("sha256").update(canonical(payload)).digest("hex")}` }; }
|
||||
|
||||
function daemonMetadata() { return { schema: 'paperclip-runner/runnerd-build-metadata/v1', binaryName: 'paperclip-runnerd',
|
||||
packageName: '@paperclipai/paperclip-runner', packageVersion: '0.0.0', binaryContractVersion: 2, nativeExecutionVersion: 1,
|
||||
harnessDriverVersion: 1, prp: { name: 'paperclip.runner', minimumVersion: 1, maximumVersion: 2 },
|
||||
durableSessionCapabilities: ['unlimited_runtime', 'connection_lease_renewal'],
|
||||
prpTransportModes: ['dial_ws_loopback', 'dial_wss', 'listen_ws'] }; }
|
||||
|
||||
test('release daemon compatibility rejects lookalikes, incompatible contracts and missing production capabilities', () => {
|
||||
assert.equal(verifyReleaseDaemonMetadata(daemonMetadata()).binaryContractVersion, 2);
|
||||
for (const mismatch of [
|
||||
{ schema: 'other' }, { binaryName: 'other' }, { packageName: 'other' }, { packageVersion: '' },
|
||||
{ binaryContractVersion: 999 }, { nativeExecutionVersion: 2 }, { harnessDriverVersion: 2 },
|
||||
{ prp: { name: 'other', minimumVersion: 1, maximumVersion: 2 } },
|
||||
{ prp: { name: 'paperclip.runner', minimumVersion: 2, maximumVersion: 2 } },
|
||||
{ prp: { name: 'paperclip.runner', minimumVersion: 1, maximumVersion: 1 } },
|
||||
{ durableSessionCapabilities: ['unlimited_runtime'] }, { prpTransportModes: ['dial_ws_loopback'] },
|
||||
]) assert.throws(() => verifyReleaseDaemonMetadata({ ...daemonMetadata(), ...mismatch }));
|
||||
});
|
||||
|
||||
function releaseData() {
|
||||
const files = new Map(), platforms = {};
|
||||
for (const target of ["darwin-arm64", "darwin-x64", "linux-x64"]) {
|
||||
const bytes = Buffer.alloc(64);
|
||||
if (target === "linux-x64") { bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); }
|
||||
else { bytes.writeUInt32LE(0xfeedfacf, 0); bytes.writeUInt32LE(target === "darwin-arm64" ? 0x0100000c : 0x01000007, 4); }
|
||||
files.set(`bin/${target}/paperclip-runnerd`, bytes);
|
||||
platforms[target] = { path: `${target}/paperclip-runnerd`, sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` };
|
||||
}
|
||||
const pack = seal(fixture());
|
||||
files.set("remote-provider-packs/linux-x64/provider-pack.json", Buffer.from(JSON.stringify(pack)));
|
||||
files.set("bin/release-manifest.json", Buffer.from(JSON.stringify({ schema: "paperclip.runner.release-binaries.v1", sourceRevision: revision,
|
||||
platforms, remoteProviderPack: { target: "linux-x64", sourceRevision: revision, digest: pack.digest } })));
|
||||
files.set("assembly-receipt.json", Buffer.from(JSON.stringify({ schema: "paperclip.runner.release-assembly.v1", sourceRevision: revision,
|
||||
sourcePins: { profiles: `sha256:${createHash("sha256").update(readFileSync(new URL('../acpx-profiles.json', import.meta.url))).digest('hex')}`,
|
||||
distributions: `sha256:${createHash("sha256").update(readFileSync(new URL('../cursor-distributions.json', import.meta.url))).digest('hex')}` },
|
||||
providerImage: { sourceRevision: revision, platform: "linux/amd64", imageId: digest },
|
||||
linuxDaemonImage: { sourceRevision: revision, platform: "linux/amd64", imageId: digest },
|
||||
daemonMetadata: Object.fromEntries(Object.keys(platforms).map(target => [target, daemonMetadata()])),
|
||||
files: Object.fromEntries([...files].map(([path, bytes]) => [path, `sha256:${createHash("sha256").update(bytes).digest("hex")}`])) })));
|
||||
return files;
|
||||
}
|
||||
|
||||
test("release transfer preserves only the assembled daemon and actual provider-pack data", () => {
|
||||
const files = releaseData(), archive = makeRunnerReleaseArchive(files, revision);
|
||||
const checksum = `sha256:${createHash("sha256").update(archive).digest("hex")}`;
|
||||
assert.deepEqual(readRunnerReleaseArchive(archive, revision, checksum), files);
|
||||
assert.throws(() => readRunnerReleaseArchive(archive, "c".repeat(40), checksum), /source mismatch/);
|
||||
assert.throws(() => readRunnerReleaseArchive(archive, revision, digest), /archive checksum mismatch/);
|
||||
});
|
||||
|
||||
test('the real transfer CLI validates before extraction, restores executable modes and refuses existing output', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'runner-release-transfer-'));
|
||||
const helper = fileURLToPath(new URL('../../../scripts/release-runner-artifacts.mjs', import.meta.url));
|
||||
try {
|
||||
const archive = makeRunnerReleaseArchive(releaseData(), revision), archivePath = join(root, 'assets.tar.gz');
|
||||
const checksumPath = join(root, 'assets.sha256'), output = join(root, 'output');
|
||||
writeFileSync(archivePath, archive); writeFileSync(checksumPath, digest);
|
||||
const invoke = (...args) => spawnSync(process.execPath, [helper, ...args], { encoding: 'utf8' });
|
||||
let result = invoke('unpack', revision, archivePath, checksumPath, output);
|
||||
assert.equal(result.status, 1); assert.match(result.stderr, /archive checksum mismatch/);
|
||||
assert.equal(existsSync(output), false, 'No data can be written before the entire envelope passes');
|
||||
writeFileSync(checksumPath, `sha256:${createHash('sha256').update(archive).digest('hex')}`);
|
||||
result = invoke('unpack', revision, archivePath, checksumPath, output);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(lstatSync(join(output, 'bin/darwin-arm64/paperclip-runnerd')).mode & 0o777, 0o755);
|
||||
assert.equal(lstatSync(join(output, 'assembly-receipt.json')).mode & 0o777, 0o644);
|
||||
result = invoke('unpack', revision, archivePath, checksumPath, output);
|
||||
assert.equal(result.status, 1); assert.match(result.stderr, /new absolute owned directory/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('image-sourced Linux daemon recording validates data without executing its ELF on the host', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'runner-image-daemon-record-'));
|
||||
const helper = fileURLToPath(new URL('../../../scripts/release-runner-artifacts.mjs', import.meta.url));
|
||||
try {
|
||||
// This header fixture cannot execute. Successful recording proves this
|
||||
// transfer leg consumes image-probed metadata rather than host execution.
|
||||
const binary = join(root, 'runnerd'), metadata = join(root, 'metadata.json'), imagePath = join(root, 'image.json');
|
||||
const image = `ghcr.io/paperclipai/paperclip@${digest}`;
|
||||
writeFileSync(binary, releaseData().get('bin/linux-x64/paperclip-runnerd'), { mode: 0o755 });
|
||||
writeFileSync(metadata, JSON.stringify(daemonMetadata()));
|
||||
const imageData = { Os: 'linux', Architecture: 'amd64', Id: digest, RepoDigests: [image],
|
||||
Config: { Labels: { 'org.opencontainers.image.revision': revision }, Env: ['PRIVATE_TOKEN=do-not-export'] } };
|
||||
writeFileSync(imagePath, JSON.stringify([imageData]));
|
||||
const output = join(root, 'output');
|
||||
let result = spawnSync(process.execPath, [helper, 'record-image-binary', revision, binary, metadata, imagePath, output, image], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = readFileSync(join(output, 'receipt.json'), 'utf8');
|
||||
assert.equal(JSON.parse(receipt).providerImage.requestedImage, image);
|
||||
assert.equal(JSON.parse(receipt).sha256, `sha256:${createHash('sha256').update(readFileSync(binary)).digest('hex')}`);
|
||||
assert.doesNotMatch(receipt, /PRIVATE_TOKEN|do-not-export|Config/);
|
||||
imageData.Config.Labels['org.opencontainers.image.revision'] = 'c'.repeat(40);
|
||||
writeFileSync(imagePath, JSON.stringify([imageData]));
|
||||
const wrong = join(root, 'wrong');
|
||||
result = spawnSync(process.execPath, [helper, 'record-image-binary', revision, binary, metadata, imagePath, wrong, image], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 1); assert.equal(existsSync(wrong), false);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("release transfer rejects links, unsafe or duplicate paths, truncation and permissions before extraction", () => {
|
||||
const archive = makeRunnerReleaseArchive(releaseData(), revision);
|
||||
for (const corrupt of [
|
||||
tar => { tar.fill(0, 0, 100); tar.write("../paperclip-runnerd", 0); },
|
||||
tar => { tar[156] = 50; },
|
||||
tar => { tar.subarray(0, 100).copy(tar, 1024); },
|
||||
tar => { tar.write("00004755\0", 100, 8); },
|
||||
]) {
|
||||
const tar = gunzipSync(archive); corrupt(tar);
|
||||
const headerStart = tar[1024] === tar[0] && tar.subarray(1024, 1124).equals(tar.subarray(0, 100)) ? 1024 : 0;
|
||||
const header = tar.subarray(headerStart, headerStart + 512); header.fill(32, 148, 156);
|
||||
const sum = [...header].reduce((sum, byte) => sum + byte, 0);
|
||||
header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8);
|
||||
const bytes = gzipSync(tar), hash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
assert.throws(() => readRunnerReleaseArchive(bytes, revision, hash), /Unsafe|forbidden|permissions/);
|
||||
}
|
||||
const truncated = archive.subarray(0, archive.length - 4);
|
||||
assert.throws(() => readRunnerReleaseArchive(truncated, revision, `sha256:${createHash("sha256").update(truncated).digest("hex")}`));
|
||||
});
|
||||
|
||||
test("release data rejects rehashed wrong targets and provider-image provenance", () => {
|
||||
const files = releaseData();
|
||||
files.set("bin/darwin-arm64/paperclip-runnerd", files.get("bin/linux-x64/paperclip-runnerd"));
|
||||
const receipt = JSON.parse(files.get("assembly-receipt.json"));
|
||||
receipt.files["bin/darwin-arm64/paperclip-runnerd"] = `sha256:${createHash("sha256").update(files.get("bin/darwin-arm64/paperclip-runnerd")).digest("hex")}`;
|
||||
files.set("assembly-receipt.json", Buffer.from(JSON.stringify(receipt)));
|
||||
assert.throws(() => verifyRunnerReleaseData(files, revision), /architecture mismatch/);
|
||||
for (const changes of [{ sourceRevision: "c".repeat(40) }, { requestedImage: `ghcr.io/paperclipai/paperclip@${digest}`, repoDigests: [] }]) {
|
||||
const source = releaseData(), receipt = JSON.parse(source.get("assembly-receipt.json"));
|
||||
Object.assign(receipt.providerImage, changes); source.set("assembly-receipt.json", Buffer.from(JSON.stringify(receipt)));
|
||||
assert.throws(() => verifyRunnerReleaseData(source, revision), /image (?:source|digest) mismatch/);
|
||||
}
|
||||
const incompatible = releaseData(), incompatibleReceipt = JSON.parse(incompatible.get('assembly-receipt.json'));
|
||||
incompatibleReceipt.daemonMetadata['linux-x64'].nativeExecutionVersion = 999;
|
||||
incompatible.set('assembly-receipt.json', Buffer.from(JSON.stringify(incompatibleReceipt)));
|
||||
assert.throws(() => verifyRunnerReleaseData(incompatible, revision), /Incompatible daemon nativeExecutionVersion/);
|
||||
const mixed = releaseData(), mixedReceipt = JSON.parse(mixed.get('assembly-receipt.json'));
|
||||
mixedReceipt.linuxDaemonImage.imageId = `sha256:${'c'.repeat(64)}`;
|
||||
mixed.set('assembly-receipt.json', Buffer.from(JSON.stringify(mixedReceipt)));
|
||||
assert.throws(() => verifyRunnerReleaseData(mixed, revision), /same verified image/);
|
||||
});
|
||||
|
||||
test("release transfer binds qualification JSON snapshots to the selected source", () => {
|
||||
const files = releaseData();
|
||||
assert.throws(() => verifyRunnerReleaseData(files, revision, { profiles: Buffer.from('{}'), distributions: Buffer.from('{}') }), /source profile snapshot mismatch/);
|
||||
for (const snapshots of [[{}, distributions], [profiles, {}], [null, null]]) {
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(fixture()), revision, ...snapshots), /Invalid release qualification snapshots/);
|
||||
}
|
||||
const noCursorVersion = structuredClone(profiles); delete noCursorVersion.profiles.cursor.agentServerVersion;
|
||||
const malformedClosure = structuredClone(distributions); malformedClosure.platforms['linux-x64'].closureSha256 = 'unknown';
|
||||
for (const snapshots of [[noCursorVersion, distributions], [profiles, malformedClosure]]) {
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(fixture()), revision, ...snapshots), /Invalid release qualification snapshots/);
|
||||
}
|
||||
const altered = structuredClone(profiles); altered.profiles.claude.commandDigest = digest;
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(fixture()), revision, altered, distributions), /ACPX profiles/);
|
||||
assert.equal(verifyReleaseProviderPack(seal(fixture()), revision, profiles, distributions).payload.runnerSourceRevision, revision);
|
||||
});
|
||||
|
||||
test("accepts a pack whose source and profiles match the assembled release", () => {
|
||||
const manifest = seal(fixture()); assert.equal(verifyReleaseProviderPack(manifest, revision), manifest);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from '../grok-public-install-sandbox.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs, installedCodexProbeSource } from '../grok-public-install-sandbox.mjs';
|
||||
|
||||
const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 };
|
||||
const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []);
|
||||
@@ -50,3 +53,25 @@ test('verification never elevates PR-controlled provisioning or cleanup on the h
|
||||
assert.doesNotMatch(source, /\bsudo\b/);
|
||||
assert.ok(source.includes("const prerequisite = join(root, 'native/grok')"));
|
||||
});
|
||||
|
||||
test('the installed Codex probe exercises the public export and rejects incomplete or mismatched packages', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'installed-codex-probe-'));
|
||||
try {
|
||||
const consumer = join(root, 'consumer'); mkdirSync(consumer);
|
||||
const index = join(consumer, 'index.mjs'), command = join(consumer, 'codex'), probe = join(root, 'probe.mjs');
|
||||
const executable = version => writeFileSync(command, `#!/bin/sh\n[ "$1" = --version ] || exit 65\nprintf '%s\\n' 'codex-cli ${version}'\n`, { mode: 0o755 });
|
||||
writeFileSync(probe, installedCodexProbeSource(index, consumer, '0.160.0'));
|
||||
const run = () => spawnSync(process.execPath, [probe], { encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin', NODE_PATH: '' } });
|
||||
writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(command)};`);
|
||||
executable('0.160.0');
|
||||
let result = run(); assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).pinnedCodexCommandVerified, true);
|
||||
assert.equal(JSON.parse(result.stdout).providerCalls, 0);
|
||||
executable('9.9.9'); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /qualified pin/);
|
||||
rmSync(command); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /ENOENT/);
|
||||
const outside = join(root, 'outside'); writeFileSync(outside, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(outside)};`);
|
||||
result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /inside the installed consumer/);
|
||||
writeFileSync(index, 'export const unrelated = true;'); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /must export the pinned Codex resolver/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -15,6 +15,110 @@ function readWorkflow(name) {
|
||||
return readFileSync(path.join(repoRoot, ".github/workflows", name), "utf8");
|
||||
}
|
||||
|
||||
function job(workflow, name) {
|
||||
return workflow.split(`\n ${name}:\n`)[1]?.split(/\n [a-z_]+:\n/)[0] ?? '';
|
||||
}
|
||||
function condition(block, needs, github = {}, inputs = {}, isCancelled = false) {
|
||||
const expression = block.match(/ if: >-\n((?: .+\n)+)/)?.[1]?.trim();
|
||||
assert.ok(expression, 'Routing must use an explicit condition, including skipped ancestors');
|
||||
return Function('needs', 'github', 'inputs', 'cancelled', `return (${expression})`)(needs, github, inputs, () => isCancelled);
|
||||
}
|
||||
|
||||
test('release producer mode validates immutable data inputs and adds only the missing native target jobs', () => {
|
||||
const workflow = readWorkflow('release-verify.yml');
|
||||
assert.match(workflow, /runner_assets_only:[\s\S]*?default: false/);
|
||||
for (const name of ['runner_chaos_evals', 'typecheck', 'general_tests', 'serialized_tests', 'runner_workflow_evals', 'verify_paperclip_runner', 'build']) {
|
||||
// The default source gate keeps all its prior checks. Producer-only mode
|
||||
// must not create a second broad suite or repeat paid eval authorization.
|
||||
assert.match(job(workflow, name), /if: \$\{\{ !inputs\.runner_assets_only \}\}/, name);
|
||||
}
|
||||
const request = job(workflow, 'runner_release_request');
|
||||
assert.doesNotMatch(request, /uses: actions\/checkout|secrets\./);
|
||||
const validation = request.split(' run: |\n')[1];
|
||||
assert.ok(validation);
|
||||
const source = 'a'.repeat(40);
|
||||
for (const [SOURCE_SHA, expected] of [[source, 0], ['master', 1], ['', 1]]) {
|
||||
const result = spawnSync('bash', ['-c', validation], { env: { ...process.env, SOURCE_SHA }, encoding: 'utf8' });
|
||||
assert.equal(result.status, expected, result.stderr);
|
||||
}
|
||||
for (const name of ['runner_release_binaries', 'runner_release_pack']) {
|
||||
const producer = job(workflow, name);
|
||||
assert.match(producer, /needs: runner_release_request/);
|
||||
assert.match(producer, /contents: read/);
|
||||
assert.doesNotMatch(producer, /secrets\.|contents: write|id-token: write|packages: write|--push|--password/);
|
||||
assert.match(producer, /test "\$\(git rev-parse HEAD\)" = "\$SOURCE_SHA"/);
|
||||
}
|
||||
const binaries = job(workflow, 'runner_release_binaries');
|
||||
assert.deepEqual([...binaries.matchAll(/- target: (.+)/g)].map(match => match[1]), ['darwin-arm64', 'darwin-x64']);
|
||||
assert.match(binaries, /cargo build --release.*--locked/);
|
||||
assert.match(binaries, /record-binary/);
|
||||
const pack = job(workflow, 'runner_release_pack');
|
||||
assert.doesNotMatch(pack, /docker pull|IMAGE_DIGEST/);
|
||||
assert.match(pack, /--target runner-provider-pack/);
|
||||
assert.match(pack, /docker cp "\$owner:\$pack_path"/);
|
||||
assert.match(pack, /daemon_path=\/app\/server\/dist\/vendor\/paperclip-runner\/bin\/paperclip-runnerd/);
|
||||
assert.match(pack, /--entrypoint "\$daemon_path" "\$image" --build-metadata/);
|
||||
assert.match(pack, /docker cp "\$owner:\$daemon_path"/);
|
||||
assert.match(pack, /timeout 30s docker start --attach "\$owner"/);
|
||||
assert.match(pack, /record-image-binary/);
|
||||
assert.match(pack, /trap 'docker rm -f "\$owner"/);
|
||||
const helper = readFileSync(path.join(repoRoot, 'scripts/release-runner-artifacts.mjs'), 'utf8');
|
||||
assert.match(helper, /stage-release-runner-binaries\.mjs/);
|
||||
assert.equal((helper.match(/execFileSync\(process\.execPath/g) ?? []).length, 1, 'Only the existing assembler owns staging');
|
||||
});
|
||||
|
||||
test('channel assembly routing preserves skipped promotions and blocks incomplete data before publishers', () => {
|
||||
const workflow = readWorkflow('release.yml');
|
||||
const skipped = () => ({ result: 'skipped', outputs: {} });
|
||||
const base = () => Object.fromEntries(['verify_canary', 'select_nightly', 'select_beta', 'verify_beta_candidate', 'preflight_stable', 'verify_stable'].map(name => [name, skipped()]));
|
||||
const pin = job(workflow, 'pin_runner_release_source');
|
||||
assert.equal(condition(pin, base(), { event_name: 'workflow_dispatch' }), false);
|
||||
for (const [name, extra, event] of [
|
||||
['verify_canary', {}, 'push'], ['select_nightly', { proceed: 'true' }, 'schedule'],
|
||||
['select_beta', { mode: 'promote' }, 'workflow_dispatch'],
|
||||
]) {
|
||||
const needs = base(); needs[name] = { result: 'success', outputs: extra };
|
||||
assert.equal(condition(pin, needs, { event_name: event }), true, `${name} with unrelated skipped gates`);
|
||||
assert.equal(condition(pin, needs, { event_name: event }, {}, true), false);
|
||||
}
|
||||
const stable = base(); stable.preflight_stable.result = 'success'; stable.verify_stable.result = 'success';
|
||||
assert.equal(condition(pin, stable, { event_name: 'workflow_dispatch' }), true);
|
||||
stable.verify_stable.result = 'failure';
|
||||
assert.equal(condition(pin, stable, { event_name: 'workflow_dispatch' }), false);
|
||||
assert.match(pin, /git show "\$source_sha:scripts\/release\.sh" \| grep -F 'PAPERCLIP_RELEASE_RUNNER_ASSETS'/);
|
||||
assert.match(pin, /git cat-file -e "\$source_sha:scripts\/release-runner-artifacts\.mjs"/);
|
||||
|
||||
const ready = { verify_canary: { result: 'success' },
|
||||
select_nightly: { result: 'success', outputs: { proceed: 'true' } }, smoke_nightly: { result: 'success' },
|
||||
select_beta: { result: 'success', outputs: { mode: 'promote' } }, verify_beta_candidate: skipped(),
|
||||
verify_stable: { result: 'success' },
|
||||
pin_runner_release_source: { result: 'success', outputs: { required: 'true' } }, release_runner_assets: { result: 'success' } };
|
||||
for (const [name, github, inputs] of [
|
||||
['publish_canary', { event_name: 'push' }, {}], ['publish_nightly', { event_name: 'schedule' }, {}],
|
||||
['publish_beta', { event_name: 'workflow_dispatch' }, {}],
|
||||
['preview_stable', { event_name: 'workflow_dispatch' }, { channel: 'stable', dry_run: true }],
|
||||
['publish_stable', { event_name: 'workflow_dispatch' }, { channel: 'stable', dry_run: false }],
|
||||
]) {
|
||||
const block = job(workflow, name);
|
||||
assert.equal(condition(block, ready, github, inputs), true, name);
|
||||
for (const result of ['failure', 'cancelled', 'skipped']) {
|
||||
assert.equal(condition(block, { ...ready, release_runner_assets: { result } }, github, inputs), false, `${name}/${result}`);
|
||||
}
|
||||
const older = { ...ready, pin_runner_release_source: { result: 'success', outputs: { required: 'false' } }, release_runner_assets: skipped() };
|
||||
assert.equal(condition(block, older, github, inputs), true, `${name} older promoted contract`);
|
||||
assert.match(block, /working-directory: source/);
|
||||
const validation = block.match(/name: Validate runner data against[\s\S]*?(?=\n - name:)/)?.[0];
|
||||
assert.ok(validation);
|
||||
assert.match(validation, /node "\$GITHUB_WORKSPACE\/trusted\/scripts\/release-runner-artifacts\.mjs" unpack/);
|
||||
assert.match(validation, /"\$RUNNER_TEMP\/runner-release-assets" "\$GITHUB_WORKSPACE\/source"/);
|
||||
assert.doesNotMatch(validation, /node scripts\//, 'Credentialed publishers may consume only trusted transfer tooling');
|
||||
}
|
||||
const release = readFileSync(path.join(repoRoot, 'scripts/release.sh'), 'utf8');
|
||||
assert.ok(release.indexOf('pnpm build\n# Production publication') < release.indexOf('PAPERCLIP_RELEASE_RUNNER_ASSETS/bin'));
|
||||
assert.ok(release.indexOf('PAPERCLIP_RELEASE_RUNNER_ASSETS/bin') < release.indexOf('node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs"'));
|
||||
assert.match(release, /elif \[ "\$dry_run" = false \]; then\n\s+release_fail/);
|
||||
});
|
||||
|
||||
test("chaos verification isolates callers that verify the same source commit", () => {
|
||||
const chaosWorkflow = readWorkflow("runner-chaos-evals.yml");
|
||||
const group = chaosWorkflow.match(/^ group: (.+)$/m)?.[1];
|
||||
@@ -45,7 +149,10 @@ test("canary reuses exact-source proof while stable keeps full verification", ()
|
||||
assert.match(canary, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(canary, /run: node scripts\/cloud-source-verification\.mjs "\$SOURCE_SHA"/);
|
||||
assert.doesNotMatch(canary, /release-verify\.yml|continue-on-error|always\(\)/);
|
||||
assert.match(releaseWorkflow, /publish_canary:\n\s+if: github\.event_name == 'push'\n\s+needs: verify_canary/);
|
||||
const publishCanary = job(releaseWorkflow, 'publish_canary');
|
||||
assert.match(publishCanary, /needs: \[verify_canary, pin_runner_release_source, release_runner_assets\]/);
|
||||
assert.match(publishCanary, /needs\.verify_canary\.result == 'success'/);
|
||||
assert.match(publishCanary, /needs\.release_runner_assets\.result == 'success'/);
|
||||
// The stable lane is gated on the stable channel since the nightly lane
|
||||
// was added; a `needs:` line (for example a preflight job) may sit between
|
||||
// the gate and the delegation.
|
||||
|
||||
@@ -7,6 +7,32 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [
|
||||
'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts',
|
||||
];
|
||||
|
||||
// Execute this in the existing offline public consumer, never in the checkout.
|
||||
// The public index and returned command must both come from the installed graph.
|
||||
export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion) {
|
||||
return `
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
||||
import { isAbsolute, relative, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const root = realpathSync(${JSON.stringify(consumerRoot)});
|
||||
const contained = path => { const value = relative(root, realpathSync(path)); return value !== '' && value !== '..' && !value.startsWith('..' + sep) && !isAbsolute(value); };
|
||||
const index = ${JSON.stringify(indexPath)};
|
||||
assert.ok(contained(index), 'Codex public index must be installed in the consumer');
|
||||
const { resolvePinnedCodexCommand } = await import(pathToFileURL(index).href);
|
||||
assert.equal(typeof resolvePinnedCodexCommand, 'function', 'Installed public index must export the pinned Codex resolver');
|
||||
const command = resolvePinnedCodexCommand();
|
||||
assert.ok(isAbsolute(command) && contained(command), 'Pinned Codex must resolve inside the installed consumer');
|
||||
assert.ok(statSync(command).isFile(), 'Pinned Codex must be a regular executable');
|
||||
accessSync(command, constants.X_OK);
|
||||
const version = execFileSync(command, ['--version'], { timeout: 30_000, maxBuffer: 128 * 1024, encoding: 'utf8',
|
||||
env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', NODE_PATH: '' } }).trim();
|
||||
assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin');
|
||||
console.log(JSON.stringify({ pinnedCodexCommandVerified: true, pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, providerCalls: 0 }));
|
||||
`;
|
||||
}
|
||||
|
||||
export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMb = 256 }) {
|
||||
if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) {
|
||||
throw new Error('Public-install verification requires an unprivileged host user');
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Data transfer for the existing release assembler. Producer jobs have no
|
||||
// publishing credentials; consumers never execute binaries from the archive.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { gzipSync, gunzipSync } from 'node:zlib';
|
||||
import { runnerBinaryTarget } from '../packages/paperclip-runner/src/live/runner-binary.ts';
|
||||
import { verifyReleaseProviderPack } from '../packages/paperclip-runner/scripts/release-provider-pack.mjs';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export const RELEASE_RUNNER_TARGETS = ['darwin-arm64', 'darwin-x64', 'linux-x64'];
|
||||
const dataPaths = [...RELEASE_RUNNER_TARGETS.map(target => `bin/${target}/paperclip-runnerd`),
|
||||
'bin/release-manifest.json', 'remote-provider-packs/linux-x64/provider-pack.json'];
|
||||
const archivePaths = [...dataPaths, 'assembly-receipt.json'];
|
||||
const maxBinary = 128 * 1024 * 1024;
|
||||
const maxArchive = 3 * maxBinary + 2 * 1024 * 1024;
|
||||
const digest = value => `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
||||
const revision = value => assert.match(value ?? '', /^[a-f0-9]{40}$/, 'Runner artifacts require a full source SHA');
|
||||
function file(path, limit = maxBinary) {
|
||||
const stat = lstatSync(path);
|
||||
assert.ok(stat.isFile() && !stat.isSymbolicLink() && stat.size <= limit, `Unsafe or oversized artifact ${path}`);
|
||||
return readFileSync(path);
|
||||
}
|
||||
function inside(root, path, limit = maxBinary) {
|
||||
const components = path.split('/');
|
||||
for (let length = 0; length < components.length; length++) {
|
||||
const directory = length === 0 ? root : join(root, ...components.slice(0, length));
|
||||
const stat = lstatSync(directory);
|
||||
assert.ok(stat.isDirectory() && !stat.isSymbolicLink(), 'Artifact directories must not be symlinks');
|
||||
}
|
||||
return file(join(root, path), limit);
|
||||
}
|
||||
function destination(root, path) {
|
||||
assert.ok(archivePaths.includes(path), 'Unknown artifact destination');
|
||||
const components = path.split('/');
|
||||
for (let length = 0; length < components.length; length++) {
|
||||
const directory = length === 0 ? root : join(root, ...components.slice(0, length));
|
||||
if (!existsSync(directory)) mkdirSync(directory);
|
||||
const stat = lstatSync(directory);
|
||||
assert.ok(stat.isDirectory() && !stat.isSymbolicLink(), 'Artifact output directories must not be symlinks');
|
||||
}
|
||||
const output = join(root, path);
|
||||
let existing;
|
||||
try { existing = lstatSync(output); }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
assert.ok(!existing || existing.isFile() && !existing.isSymbolicLink() && existing.nlink === 1,
|
||||
'Refuse to overwrite an artifact link or non-file');
|
||||
return output;
|
||||
}
|
||||
function newDirectory(path) {
|
||||
assert.ok(isAbsolute(path) && resolve(path) === path && path !== '/' && !existsSync(path), 'Artifact output must be a new absolute owned directory');
|
||||
mkdirSync(path, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
function writeJson(path, value) { writeFileSync(path, JSON.stringify(value, null, 2) + '\n'); }
|
||||
function imageProvenance(path, sourceRevision, requestedImage = '') {
|
||||
const [image] = JSON.parse(file(path, 512 * 1024));
|
||||
assert.equal(image.Os, 'linux'); assert.equal(image.Architecture, 'amd64');
|
||||
assert.equal(image.Config?.Labels?.['org.opencontainers.image.revision'], sourceRevision);
|
||||
assert.match(image.Id, /^sha256:[a-f0-9]{64}$/);
|
||||
if (requestedImage) {
|
||||
assert.match(requestedImage, /^ghcr\.io\/paperclipai\/paperclip@sha256:[a-f0-9]{64}$/);
|
||||
assert.ok(image.RepoDigests?.includes(requestedImage));
|
||||
}
|
||||
return { sourceRevision, platform: 'linux/amd64', imageId: image.Id, requestedImage, repoDigests: image.RepoDigests ?? [] };
|
||||
}
|
||||
|
||||
function sourcePins(sourceRoot = repo) {
|
||||
return { profiles: file(join(sourceRoot, 'packages/paperclip-runner/acpx-profiles.json'), 512 * 1024),
|
||||
distributions: file(join(sourceRoot, 'packages/paperclip-runner/cursor-distributions.json'), 512 * 1024) };
|
||||
}
|
||||
// The unbuilt producer cannot import the compiled eval join. Check its same
|
||||
// daemon contracts here, including the durable/remote capabilities needed by
|
||||
// production. Metadata is a compatibility claim, not embedded source proof.
|
||||
export function verifyReleaseDaemonMetadata(metadata) {
|
||||
assert.equal(metadata?.schema, 'paperclip-runner/runnerd-build-metadata/v1');
|
||||
assert.equal(metadata.binaryName, 'paperclip-runnerd', 'Unexpected release daemon name');
|
||||
assert.equal(metadata.packageName, '@paperclipai/paperclip-runner', 'Unexpected release daemon package');
|
||||
assert.ok(typeof metadata.packageVersion === 'string' && metadata.packageVersion.trim(), 'Missing daemon package version');
|
||||
for (const [field, expected] of [['binaryContractVersion', 2], ['nativeExecutionVersion', 1], ['harnessDriverVersion', 1]]) {
|
||||
assert.equal(metadata[field], expected, `Incompatible daemon ${field}`);
|
||||
}
|
||||
assert.equal(metadata.prp?.name, 'paperclip.runner', 'Incompatible daemon protocol family');
|
||||
assert.ok(Number.isSafeInteger(metadata.prp.minimumVersion) && metadata.prp.minimumVersion === 1 &&
|
||||
Number.isSafeInteger(metadata.prp.maximumVersion) && metadata.prp.maximumVersion >= 2, 'Incompatible daemon protocol versions');
|
||||
for (const [field, required] of [
|
||||
['durableSessionCapabilities', ['unlimited_runtime', 'connection_lease_renewal']],
|
||||
['prpTransportModes', ['dial_ws_loopback', 'dial_wss', 'listen_ws']],
|
||||
]) {
|
||||
assert.ok(Array.isArray(metadata[field]) && required.every(capability => metadata[field].includes(capability)),
|
||||
`Missing release daemon ${field}`);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
export function verifyRunnerReleaseData(files, sourceRevision, pins = sourcePins()) {
|
||||
revision(sourceRevision);
|
||||
assert.deepEqual([...files.keys()].sort(), [...archivePaths].sort(), 'Release archive must contain exactly the known data files');
|
||||
const receipt = JSON.parse(files.get('assembly-receipt.json'));
|
||||
assert.equal(receipt.schema, 'paperclip.runner.release-assembly.v1');
|
||||
assert.equal(receipt.sourceRevision, sourceRevision, 'Assembly source mismatch');
|
||||
assert.deepEqual(Object.keys(receipt.files ?? {}).sort(), [...dataPaths].sort());
|
||||
for (const path of dataPaths) assert.equal(digest(files.get(path)), receipt.files[path], `Assembly artifact digest mismatch: ${path}`);
|
||||
const manifest = JSON.parse(files.get('bin/release-manifest.json'));
|
||||
assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1');
|
||||
assert.equal(manifest.sourceRevision, sourceRevision, 'Daemon source mismatch');
|
||||
assert.deepEqual(Object.keys(manifest.platforms ?? {}).sort(), [...RELEASE_RUNNER_TARGETS].sort(), 'Missing or duplicate release target');
|
||||
assert.deepEqual(Object.keys(receipt.daemonMetadata ?? {}).sort(), [...RELEASE_RUNNER_TARGETS].sort(), 'Missing daemon compatibility receipt');
|
||||
for (const target of RELEASE_RUNNER_TARGETS) {
|
||||
const path = `bin/${target}/paperclip-runnerd`, artifact = manifest.platforms[target];
|
||||
assert.equal(artifact.path, `${target}/paperclip-runnerd`, 'Unsafe daemon path');
|
||||
assert.equal(runnerBinaryTarget(files.get(path)), target, 'Daemon architecture mismatch');
|
||||
assert.equal(digest(files.get(path)), artifact.sha256, 'Daemon digest mismatch');
|
||||
verifyReleaseDaemonMetadata(receipt.daemonMetadata[target]);
|
||||
}
|
||||
assert.equal(receipt.sourcePins?.profiles, digest(pins.profiles), 'Checked-out source profile snapshot mismatch');
|
||||
assert.equal(receipt.sourcePins?.distributions, digest(pins.distributions), 'Checked-out source distribution snapshot mismatch');
|
||||
const pack = verifyReleaseProviderPack(JSON.parse(files.get('remote-provider-packs/linux-x64/provider-pack.json')), sourceRevision,
|
||||
JSON.parse(pins.profiles), JSON.parse(pins.distributions));
|
||||
assert.equal(manifest.remoteProviderPack?.target, 'linux-x64');
|
||||
assert.equal(manifest.remoteProviderPack?.digest, pack.digest, 'Provider pack assembly identity mismatch');
|
||||
assert.equal(manifest.remoteProviderPack?.sourceRevision, sourceRevision);
|
||||
assert.equal(receipt.providerImage?.sourceRevision, sourceRevision, 'Provider image source mismatch');
|
||||
assert.equal(receipt.providerImage?.platform, 'linux/amd64');
|
||||
assert.match(receipt.providerImage?.imageId ?? '', /^sha256:[a-f0-9]{64}$/);
|
||||
if (receipt.providerImage.requestedImage) {
|
||||
assert.match(receipt.providerImage.requestedImage, /^ghcr\.io\/paperclipai\/paperclip@sha256:[a-f0-9]{64}$/);
|
||||
assert.ok(receipt.providerImage.repoDigests?.includes(receipt.providerImage.requestedImage), 'Provider image digest mismatch');
|
||||
}
|
||||
assert.deepEqual(receipt.linuxDaemonImage, receipt.providerImage, 'Linux daemon and provider pack must come from the same verified image');
|
||||
return receipt;
|
||||
}
|
||||
|
||||
// A deliberately small ustar data envelope: no directories, links, extensions,
|
||||
// executable hooks or arbitrary paths. Validate the entire envelope before any
|
||||
// destination file exists, including compressed/uncompressed bounds and hashes.
|
||||
export function readRunnerReleaseArchive(bytes, sourceRevision, checksum, pins) {
|
||||
assert.ok(bytes.length <= maxArchive, 'Release archive is oversized');
|
||||
assert.equal(digest(bytes), checksum, 'Release archive checksum mismatch');
|
||||
const tar = gunzipSync(bytes, { maxOutputLength: maxArchive });
|
||||
const files = new Map();
|
||||
const octal = bytes => {
|
||||
const value = bytes.toString('ascii').replace(/\0.*$/s, '').trim();
|
||||
assert.match(value, /^[0-7]+$/, 'Invalid archive numeric field');
|
||||
return parseInt(value, 8);
|
||||
};
|
||||
let offset = 0;
|
||||
while (offset + 512 <= tar.length) {
|
||||
const header = tar.subarray(offset, offset + 512);
|
||||
if (header.every(byte => byte === 0)) {
|
||||
assert.ok(tar.subarray(offset).every(byte => byte === 0), 'Unexpected archive trailer');
|
||||
break;
|
||||
}
|
||||
const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/s, '');
|
||||
assert.ok(archivePaths.includes(name) && !files.has(name), 'Unsafe, unexpected or duplicate archive path');
|
||||
assert.ok(header.subarray(345, 500).every(byte => byte === 0), 'Archive prefixes are unsupported');
|
||||
assert.ok(header[156] === 0 || header[156] === 48, 'Archive links and extensions are forbidden');
|
||||
assert.ok(header.subarray(157, 257).every(byte => byte === 0), 'Archive link target is forbidden');
|
||||
const sum = [...header].reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0);
|
||||
assert.equal(octal(header.subarray(148, 156)), sum, 'Archive header checksum mismatch');
|
||||
const size = octal(header.subarray(124, 136));
|
||||
assert.ok(size <= (name.endsWith('paperclip-runnerd') ? maxBinary : 512 * 1024) && offset + 512 + size <= tar.length, 'Oversized or truncated archive member');
|
||||
const mode = octal(header.subarray(100, 108));
|
||||
assert.equal(mode, name.endsWith('paperclip-runnerd') ? 0o755 : 0o644, 'Unexpected archive file permissions');
|
||||
files.set(name, tar.subarray(offset + 512, offset + 512 + size));
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
assert.ok(offset + 1024 <= tar.length, 'Archive must have a complete end marker');
|
||||
verifyRunnerReleaseData(files, sourceRevision, pins);
|
||||
return files;
|
||||
}
|
||||
|
||||
export function makeRunnerReleaseArchive(files, sourceRevision) {
|
||||
verifyRunnerReleaseData(files, sourceRevision);
|
||||
const chunks = [];
|
||||
for (const path of archivePaths) {
|
||||
const bytes = files.get(path), header = Buffer.alloc(512);
|
||||
header.write(path);
|
||||
const field = (offset, width, value) => header.write(value.toString(8).padStart(width - 1, '0') + '\0', offset, width, 'ascii');
|
||||
field(100, 8, path.endsWith('paperclip-runnerd') ? 0o755 : 0o644);
|
||||
field(108, 8, 0); field(116, 8, 0); field(124, 12, bytes.length); field(136, 12, 0);
|
||||
header.fill(32, 148, 156); header[156] = 48;
|
||||
header.write('ustar\0', 257, 6, 'ascii'); header.write('00', 263, 2, 'ascii');
|
||||
const sum = [...header].reduce((sum, byte) => sum + byte, 0);
|
||||
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
|
||||
chunks.push(header, bytes, Buffer.alloc((512 - bytes.length % 512) % 512));
|
||||
}
|
||||
chunks.push(Buffer.alloc(1024));
|
||||
return gzipSync(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [mode, sourceRevision, ...args] = process.argv.slice(2);
|
||||
revision(sourceRevision);
|
||||
if (mode === 'record-binary') {
|
||||
const [target, binary, output, ...extra] = args;
|
||||
assert.ok(RELEASE_RUNNER_TARGETS.includes(target) && !extra.length);
|
||||
const bytes = file(binary); assert.equal(runnerBinaryTarget(bytes), target);
|
||||
assert.equal(`${process.platform}-${process.arch}`, target, 'Execute metadata only on the daemon native target');
|
||||
const metadata = JSON.parse(execFileSync(binary, ['--build-metadata'], { timeout: 15_000, maxBuffer: 128 * 1024 }));
|
||||
verifyReleaseDaemonMetadata(metadata);
|
||||
newDirectory(output); writeFileSync(join(output, 'paperclip-runnerd'), bytes, { mode: 0o755 });
|
||||
writeJson(join(output, 'receipt.json'), { sourceRevision, target, sha256: digest(bytes), metadata });
|
||||
} else if (mode === 'record-image-binary') {
|
||||
const [binary, metadataPath, imageMetadata, output, requestedImage = '', ...extra] = args;
|
||||
assert.equal(extra.length, 0);
|
||||
const bytes = file(binary); assert.equal(runnerBinaryTarget(bytes), 'linux-x64');
|
||||
// Metadata was executed in this same image, whose libc matches the daemon.
|
||||
// The host validates data only and must never execute the extracted ELF.
|
||||
const metadata = verifyReleaseDaemonMetadata(JSON.parse(file(metadataPath, 128 * 1024)));
|
||||
const providerImage = imageProvenance(imageMetadata, sourceRevision, requestedImage);
|
||||
newDirectory(output); writeFileSync(join(output, 'paperclip-runnerd'), bytes, { mode: 0o755 });
|
||||
writeJson(join(output, 'receipt.json'), { sourceRevision, target: 'linux-x64', sha256: digest(bytes), metadata, providerImage });
|
||||
} else if (mode === 'record-pack') {
|
||||
const [packPath, imageMetadata, output, requestedImage = '', ...extra] = args; assert.equal(extra.length, 0);
|
||||
const bytes = file(packPath, 512 * 1024); verifyReleaseProviderPack(JSON.parse(bytes), sourceRevision);
|
||||
const providerImage = imageProvenance(imageMetadata, sourceRevision, requestedImage);
|
||||
newDirectory(output); writeFileSync(join(output, 'provider-pack.json'), bytes);
|
||||
writeJson(join(output, 'receipt.json'), { sourceRevision, sha256: digest(bytes), providerImage });
|
||||
} else if (mode === 'assemble') {
|
||||
const [artifacts, output, ...extra] = args; assert.equal(extra.length, 0);
|
||||
const platforms = {}, daemonMetadata = {};
|
||||
let linuxDaemonImage;
|
||||
assert.deepEqual((await import('node:fs')).readdirSync(artifacts).sort(), [...RELEASE_RUNNER_TARGETS, 'provider-pack'].sort());
|
||||
for (const target of RELEASE_RUNNER_TARGETS) {
|
||||
const path = join(artifacts, target, 'paperclip-runnerd'), bytes = inside(artifacts, `${target}/paperclip-runnerd`);
|
||||
const receipt = JSON.parse(inside(artifacts, `${target}/receipt.json`, 128 * 1024));
|
||||
assert.equal(receipt.sourceRevision, sourceRevision); assert.equal(receipt.target, target);
|
||||
daemonMetadata[target] = verifyReleaseDaemonMetadata(receipt.metadata);
|
||||
if (target === 'linux-x64') linuxDaemonImage = receipt.providerImage;
|
||||
assert.equal(receipt.sha256, digest(bytes)); assert.equal(runnerBinaryTarget(bytes), target);
|
||||
platforms[target] = { path: resolve(path), sha256: receipt.sha256 };
|
||||
}
|
||||
const packPath = join(artifacts, 'provider-pack/provider-pack.json'), bytes = inside(artifacts, 'provider-pack/provider-pack.json', 512 * 1024);
|
||||
const packReceipt = JSON.parse(inside(artifacts, 'provider-pack/receipt.json', 128 * 1024));
|
||||
assert.equal(packReceipt.sourceRevision, sourceRevision); assert.equal(packReceipt.sha256, digest(bytes));
|
||||
assert.deepEqual(linuxDaemonImage, packReceipt.providerImage, 'Linux daemon and provider pack must come from the same verified image');
|
||||
verifyReleaseProviderPack(JSON.parse(bytes), sourceRevision);
|
||||
newDirectory(output);
|
||||
const manifestPath = join(output, 'assembler-input.json');
|
||||
writeJson(manifestPath, { sourceRevision, platforms, remoteProviderPack: { path: resolve(packPath), sha256: packReceipt.sha256 } });
|
||||
// The existing assembler owns source/profile-aware staging. Never replace
|
||||
// it with a second daemon assembly implementation in this transfer helper.
|
||||
execFileSync(process.execPath, [join(repo, 'packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'), manifestPath], { stdio: 'inherit' });
|
||||
const files = new Map(dataPaths.map(path => [path, file(join(repo, 'packages/paperclip-runner/dist', path), path.endsWith('paperclip-runnerd') ? maxBinary : 512 * 1024)]));
|
||||
const pins = sourcePins();
|
||||
const receipt = { schema: 'paperclip.runner.release-assembly.v1', sourceRevision,
|
||||
sourcePins: { profiles: digest(pins.profiles), distributions: digest(pins.distributions) }, daemonMetadata,
|
||||
providerImage: packReceipt.providerImage, linuxDaemonImage, files: Object.fromEntries([...files].map(([path, bytes]) => [path, digest(bytes)])) };
|
||||
files.set('assembly-receipt.json', Buffer.from(JSON.stringify(receipt, null, 2) + '\n'));
|
||||
const archive = makeRunnerReleaseArchive(files, sourceRevision);
|
||||
writeFileSync(join(output, 'runner-release-assets.tar.gz'), archive);
|
||||
writeFileSync(join(output, 'runner-release-assets.tar.gz.sha256'), digest(archive) + '\n');
|
||||
} else if (mode === 'unpack') {
|
||||
const [archive, checksum, output, sourceRoot = repo, ...extra] = args; assert.equal(extra.length, 0);
|
||||
const files = readRunnerReleaseArchive(file(archive, maxArchive), sourceRevision, file(checksum, 256).toString().trim(), sourcePins(sourceRoot));
|
||||
newDirectory(output);
|
||||
for (const [path, bytes] of files) {
|
||||
writeFileSync(destination(output, path), bytes, { mode: path.endsWith('paperclip-runnerd') ? 0o755 : 0o644 });
|
||||
}
|
||||
console.log(JSON.stringify({ sourceRevision, targets: RELEASE_RUNNER_TARGETS, archiveSha256: digest(file(archive, maxArchive)), providerCalls: 0 }));
|
||||
} else throw new Error('Unknown release artifact operation');
|
||||
}
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) await main();
|
||||
@@ -284,6 +284,37 @@ release_info ""
|
||||
release_info "==> Step 2/7: Building workspace artifacts..."
|
||||
cd "$REPO_ROOT"
|
||||
pnpm build
|
||||
# Production publication requires the data envelope from the separately gated,
|
||||
# credential-free assembler. The publisher never executes its producer tooling.
|
||||
# A developer dry-run may still inspect a host-only payload; it does not qualify
|
||||
# a release. Official dry-runs receive the same complete assets as publication.
|
||||
if [ -n "${PAPERCLIP_RELEASE_RUNNER_ASSETS:-}" ]; then
|
||||
node --input-type=module - "$PAPERCLIP_RELEASE_RUNNER_ASSETS" "$CURRENT_SHA" <<'NODE'
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstatSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
const [assets, source] = process.argv.slice(2);
|
||||
const receipt = JSON.parse(readFileSync(join(assets, 'assembly-receipt.json')));
|
||||
assert.equal(receipt.schema, 'paperclip.runner.release-assembly.v1');
|
||||
assert.equal(receipt.sourceRevision, source, 'Release runner source mismatch');
|
||||
const paths = ['bin/darwin-arm64/paperclip-runnerd', 'bin/darwin-x64/paperclip-runnerd', 'bin/linux-x64/paperclip-runnerd',
|
||||
'bin/release-manifest.json', 'remote-provider-packs/linux-x64/provider-pack.json'];
|
||||
assert.deepEqual(Object.keys(receipt.files).sort(), paths.sort());
|
||||
for (const path of paths) {
|
||||
const target = join(assets, path), stat = lstatSync(target);
|
||||
assert.ok(stat.isFile() && !stat.isSymbolicLink());
|
||||
assert.equal(`sha256:${createHash('sha256').update(readFileSync(target)).digest('hex')}`, receipt.files[path]);
|
||||
}
|
||||
NODE
|
||||
cp -Rf "$PAPERCLIP_RELEASE_RUNNER_ASSETS/bin" "$REPO_ROOT/packages/paperclip-runner/dist/"
|
||||
cp -Rf "$PAPERCLIP_RELEASE_RUNNER_ASSETS/remote-provider-packs" "$REPO_ROOT/packages/paperclip-runner/dist/"
|
||||
cp -Rf "$REPO_ROOT/packages/paperclip-runner/dist/." "$REPO_ROOT/server/dist/vendor/paperclip-runner/"
|
||||
elif [ "$dry_run" = false ]; then
|
||||
release_fail "complete release runner assets are required; use the gated release workflow or supply PAPERCLIP_RELEASE_RUNNER_ASSETS."
|
||||
else
|
||||
release_info " Host-only dry-run: release runner matrix is not qualified."
|
||||
fi
|
||||
node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs"
|
||||
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
|
||||
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
|
||||
|
||||
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from './grok-public-install-sandbox.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs, installedCodexProbeSource } from './grok-public-install-sandbox.mjs';
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host');
|
||||
const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-'));
|
||||
@@ -94,6 +94,11 @@ try {
|
||||
assert.equal(existsSync(prerequisite), false, 'npm must not provision Grok');
|
||||
const server = join(consumer, 'node_modules/@paperclipai/server');
|
||||
const installed = join(server, 'dist/vendor/paperclip-runner');
|
||||
const codexVersion = JSON.parse(readFileSync(join(repo, 'packages/paperclip-runner/acpx-profiles.json'), 'utf8')).profiles.codex.agentRuntimeVersion;
|
||||
assert.match(codexVersion, /^\d+\.\d+\.\d+$/);
|
||||
writeFileSync(join(assets, 'codex-probe.mjs'), installedCodexProbeSource(
|
||||
'/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/index.js', '/consumer', codexVersion), { mode: 0o644 });
|
||||
isolated(['node', '/packages/codex-probe.mjs']);
|
||||
assert.ok(existsSync(join(installed, 'providers/grok/launcher.cjs')));
|
||||
assert.equal(existsSync(join(consumer, 'node_modules/@paperclipai/grok-acp')), false);
|
||||
assert.equal(existsSync(join(installed, 'providers/grok/bin')), false);
|
||||
@@ -114,7 +119,7 @@ try {
|
||||
// Only the positive probe sees this file at the canonical sandbox path.
|
||||
run(process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), prerequisite]);
|
||||
isolated(['node', '/packages/probe.mjs', 'present'], { prerequisite });
|
||||
console.log(JSON.stringify({ schema: 'paperclip.grok.public-npm-install.v1', sourceRevision, releaseVersion, lifecycleScriptsEnabled: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerUid, consumerLockPreserved: true, cleanNpmInstall: true, packageCount: needed.size, builtinLauncherPresent: true, separateGrokPackage: false, npmProvisionedBinary: false, missingPrerequisiteRejected: true, provisionedBinaryVerified: true, commandLeaseVerified: true, providerCalls: 0 }));
|
||||
console.log(JSON.stringify({ schema: 'paperclip.grok.public-npm-install.v1', sourceRevision, releaseVersion, lifecycleScriptsEnabled: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerUid, consumerLockPreserved: true, cleanNpmInstall: true, packageCount: needed.size, pinnedCodexCommandVerified: true, pinnedCodexVersion: codexVersion, builtinLauncherPresent: true, separateGrokPackage: false, npmProvisionedBinary: false, missingPrerequisiteRejected: true, provisionedBinaryVerified: true, commandLeaseVerified: true, providerCalls: 0 }));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user