Skip to content
Draft
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
58 changes: 58 additions & 0 deletions .github/actions/driver-bundle/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Package and verify driver bundle
description: Build a deterministic Engine/PCK/bridge driver ZIP and verify it.
inputs:
engine:
description: Path to the canonical Engine input
required: true
pack:
description: Path to the canonical runtime PCK input
required: true
bridge:
description: Path to the canonical interpreter bridge input
required: true
output:
description: Output driver ZIP path
required: true
descriptor:
description: Output driver descriptor JSON path
required: true
goos:
description: Target GOOS
required: true
goarch:
description: Target GOARCH
required: true
runs:
using: composite
steps:
- name: Package driver bundle
shell: bash
env:
ENGINE_PATH: ${{ inputs.engine }}
PACK_PATH: ${{ inputs.pack }}
BRIDGE_PATH: ${{ inputs.bridge }}
OUTPUT_PATH: ${{ inputs.output }}
DESCRIPTOR_PATH: ${{ inputs.descriptor }}
TARGET_GOOS: ${{ inputs.goos }}
TARGET_GOARCH: ${{ inputs.goarch }}
run: |
set -euo pipefail
go run ./.github/scripts/driverbundle package \
--engine "$ENGINE_PATH" \
--pack "$PACK_PATH" \
--bridge "$BRIDGE_PATH" \
--output "$OUTPUT_PATH" \
--descriptor "$DESCRIPTOR_PATH" \
--goos "$TARGET_GOOS" \
--goarch "$TARGET_GOARCH"

- name: Verify driver bundle
shell: bash
env:
OUTPUT_PATH: ${{ inputs.output }}
DESCRIPTOR_PATH: ${{ inputs.descriptor }}
run: |
set -euo pipefail
go run ./.github/scripts/driverbundle verify \
--output "$OUTPUT_PATH" \
--descriptor "$DESCRIPTOR_PATH"
37 changes: 32 additions & 5 deletions .github/actions/standalone/prepare/action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Prepare standalone release
description: Prepare runtime assets, reinstall spx, and expose the rebuilt binary path.
description: Prepare runtime assets and expose rebuilt SPX component paths.
inputs:
engine-asset-dir:
description: 'Optional directory containing engine artifacts from the current workflow run'
Expand All @@ -9,6 +9,15 @@ outputs:
spx-bin:
description: 'Path to the rebuilt spx binary'
value: ${{ steps.prepare-standalone-release.outputs.spx-bin }}
engine-path:
description: 'Path to the verified host Engine'
value: ${{ steps.prepare-standalone-release.outputs.engine-path }}
pack-path:
description: 'Path to the verified runtime PCK'
value: ${{ steps.prepare-standalone-release.outputs.pack-path }}
bridge-path:
description: 'Path to the rebuilt interpreter bridge'
value: ${{ steps.prepare-standalone-release.outputs.bridge-path }}
runs:
using: "composite"
steps:
Expand Down Expand Up @@ -37,10 +46,28 @@ runs:
"$BUILDCTL" setup "${setup_args[@]}"

GOEXE="$(go_env_goexe)"
GOOS="$(go env GOOS | tr -d '\r')"
GOARCH="$(go env GOARCH | tr -d '\r')"
RUNTIME_VERSION="$(go run ./.github/scripts/runtime/version.go | tr -d '\r\n')"
GO_BIN_DIR="$(go_env_bin_dir "$GOEXE")"
SPX_BIN_PATH="$(go_env_spx_path "$GOEXE")"
if [ ! -f "$SPX_BIN_PATH" ]; then
log_error "Rebuilt SPX binary not found: $SPX_BIN_PATH"
exit 1
fi
ENGINE_PATH="$GO_BIN_DIR/gdspxrt${RUNTIME_VERSION}${GOEXE}"
PACK_PATH="$GO_BIN_DIR/gdspxrt${RUNTIME_VERSION}.pck"
case "$GOOS" in
darwin) BRIDGE_EXT=dylib ;;
linux) BRIDGE_EXT=so ;;
windows) BRIDGE_EXT=dll ;;
*) log_error "Unsupported driver platform: $GOOS/$GOARCH"; exit 1 ;;
esac
BRIDGE_PATH="$GO_BIN_DIR/gdspx-${GOOS}-${GOARCH}.${BRIDGE_EXT}"
for asset in "$SPX_BIN_PATH" "$ENGINE_PATH" "$PACK_PATH" "$BRIDGE_PATH"; do
if [ ! -f "$asset" ]; then
log_error "Prepared release asset not found: $asset"
exit 1
fi
done
echo "spx-bin=$SPX_BIN_PATH" >> "$GITHUB_OUTPUT"
echo "engine-path=$ENGINE_PATH" >> "$GITHUB_OUTPUT"
echo "pack-path=$PACK_PATH" >> "$GITHUB_OUTPUT"
echo "bridge-path=$BRIDGE_PATH" >> "$GITHUB_OUTPUT"
log_success "Rebuilt spx binary: $SPX_BIN_PATH"
225 changes: 225 additions & 0 deletions .github/scripts/driverbundle/files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package main

import (
"errors"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"runtime"

"github.com/goplus/spx/v3/internal/driverbundle"
)

func openRegularFile(path string) (*os.File, os.FileInfo, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, nil, &notRegularError{path: path}
}
file, err := os.Open(path)
if err != nil {
return nil, nil, err
}
opened, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, nil, err
}
if !os.SameFile(info, opened) {
_ = file.Close()
return nil, nil, &changedFileError{path: path}
}
return file, info, nil
}

func readRegularFile(path string) ([]byte, error) {
file, _, err := openRegularFile(path)
if err != nil {
return nil, err
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, driverbundle.MaxManifestSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > driverbundle.MaxManifestSize {
return nil, fmt.Errorf("descriptor exceeds %d-byte limit", driverbundle.MaxManifestSize)
}
return data, nil
}

func rejectOutputAliases(outputPath, descriptorPath string, inputs ...string) error {
outputs := []string{outputPath, descriptorPath}
absoluteOutputs := make([]string, len(outputs))
for i, path := range outputs {
value, err := filepath.Abs(path)
if err != nil {
return err
}
absoluteOutputs[i] = filepath.Clean(value)
}
if absoluteOutputs[0] == absoluteOutputs[1] {
return &aliasError{first: outputPath, second: descriptorPath}
}
for outputIndex, output := range outputs {
for _, input := range inputs {
inputAbs, err := filepath.Abs(input)
if err != nil {
return err
}
if absoluteOutputs[outputIndex] == filepath.Clean(inputAbs) {
return &aliasError{first: output, second: input}
}
outputInfo, outputErr := os.Stat(output)
inputInfo, inputErr := os.Stat(input)
if outputErr == nil && inputErr == nil && os.SameFile(outputInfo, inputInfo) {
return &aliasError{first: output, second: input}
}
}
}
return nil
}

func ensureParent(path string) error {
return os.MkdirAll(filepath.Dir(path), 0o755)
}

func atomicWrite(path string, data []byte, mode os.FileMode) error {
temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-")
if err != nil {
return err
}
temporaryPath := temporary.Name()
committed := false
defer func() {
_ = temporary.Close()
if !committed {
_ = os.Remove(temporaryPath)
}
}()
if _, err := temporary.Write(data); err != nil {
return err
}
if err := temporary.Chmod(mode); err != nil {
return err
}
if err := temporary.Sync(); err != nil {
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, path); err != nil {
return err
}
committed = true
if err := syncDirectory(filepath.Dir(path)); err != nil && runtime.GOOS != "windows" {
return err
}
return nil
}

func copyRegularFile(source, destination string) error {
if filepath.Clean(source) == filepath.Clean(destination) {
return errors.New("source and destination are the same path")
}
input, info, err := openRegularFile(source)
if err != nil {
return err
}
defer input.Close()
if err := ensureParent(destination); err != nil {
return err
}
temporary, err := os.CreateTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".tmp-")
if err != nil {
return err
}
temporaryPath := temporary.Name()
committed := false
defer func() {
_ = temporary.Close()
if !committed {
_ = os.Remove(temporaryPath)
}
}()
count, err := io.Copy(temporary, input)
if err != nil {
return err
}
if count != info.Size() {
return fmt.Errorf("copied size %d, want %d", count, info.Size())
}
if err := temporary.Chmod(0o644); err != nil {
return err
}
if err := temporary.Sync(); err != nil {
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, destination); err != nil {
return err
}
committed = true
return nil
}

type digestWriter struct {
writer io.Writer
digest hash.Hash
size int64
}

func (w *digestWriter) Write(data []byte) (int, error) {
count, err := w.writer.Write(data)
if count > 0 {
if _, digestErr := w.digest.Write(data[:count]); err == nil {
err = digestErr
}
w.size += int64(count)
}
return count, err
}

func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return err
}
defer directory.Close()
return directory.Sync()
}

type notRegularError struct{ path string }

func (e *notRegularError) Error() string { return e.path + " is not a regular non-symlink file" }

type changedFileError struct{ path string }

func (e *changedFileError) Error() string { return e.path + " changed while opening" }

type aliasError struct{ first, second string }

func (e *aliasError) Error() string { return "paths alias: " + e.first + " and " + e.second }
58 changes: 58 additions & 0 deletions .github/scripts/driverbundle/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Command driverbundle packages and verifies the host driver release bundle.
package main

import (
"fmt"
"io"
"os"
)

func main() {
if len(os.Args) < 2 {
usage(os.Stderr)
os.Exit(2)
}

var err error
switch os.Args[1] {
case "package":
err = runPackage(os.Args[2:])
case "verify":
err = runVerify(os.Args[2:])
case "assemble":
err = runAssemble(os.Args[2:])
case "verify-release":
err = runVerifyRelease(os.Args[2:])
case "check-prerequisites":
err = runCheckPrerequisites(os.Args[2:])
case "-h", "--help", "help":
usage(os.Stdout)
return
default:
err = fmt.Errorf("unknown command %q (want package, verify, assemble, verify-release, or check-prerequisites)", os.Args[1])
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func usage(output io.Writer) {
fmt.Fprintln(output, "usage: driverbundle package|verify|assemble|verify-release|check-prerequisites [flags]")
}
Loading
Loading