Files
tutti/.github/workflows/publish-tutti-app-runtime.yml
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

661 lines
32 KiB
YAML

name: Publish Tutti App Runtime
on:
push:
branches:
- main
paths:
- config/tutti.app-runtime.lock.json
workflow_dispatch:
inputs:
aws_region:
description: AWS region. Defaults to runtime vars, then release vars.
required: false
type: string
aws_role_arn:
description: AWS role ARN. Defaults to runtime vars, then release vars.
required: false
type: string
s3_bucket:
description: S3 bucket. Defaults to runtime vars, then release vars.
required: false
type: string
s3_prefix:
description: S3 prefix. Defaults to runtime vars or tutti-app-runtimes.
required: false
type: string
artifact_base_url:
description: Public HTTPS base URL matching the S3 prefix, usually a CloudFront URL.
required: false
type: string
cloudfront_distribution_id:
description: Optional CloudFront distribution id to invalidate after catalog upload.
required: false
type: string
permissions:
contents: read
id-token: write
concurrency:
group: tutti-app-runtime-production
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
RUNTIME_LOCK_FILE: config/tutti.app-runtime.lock.json
jobs:
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: darwin-arm64
runner: macos-15
node_distribution: darwin-arm64
- platform: darwin-amd64
runner: macos-15-intel
node_distribution: darwin-x64
- platform: linux-amd64
runner: ubuntu-latest
node_distribution: linux-x64
- platform: windows-amd64
runner: windows-latest
node_distribution: win-x64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .node-version
- name: Resolve runtime lock
id: lock
shell: bash
env:
PLATFORM: ${{ matrix.platform }}
run: |
node <<'NODE' >> "$GITHUB_OUTPUT"
const fs = require("node:fs");
const lock = JSON.parse(fs.readFileSync(process.env.RUNTIME_LOCK_FILE, "utf8"));
const platform = process.env.PLATFORM;
if (!lock.platforms?.[platform]) {
throw new Error(`runtime lock does not contain ${platform}`);
}
const pythonVersion =
platform === "windows-amd64"
? lock.python?.windows?.version
: lock.python?.version;
const rtkArtifact = lock.rtk?.artifacts?.[platform];
for (const [name, value] of [
["runtime_version", lock.runtimeVersion],
["uv_version", lock.uv?.version],
["python_version", pythonVersion],
["python_windows_archive_url", lock.python?.windows?.archiveUrl],
["python_windows_archive_sha256", lock.python?.windows?.archiveSha256],
["python_windows_archive_root", lock.python?.windows?.archiveRoot],
["node_version", lock.node?.version],
["node_release_base_url", lock.node?.releaseBaseUrl],
["rtk_version", lock.rtk?.version],
["rtk_archive_url", rtkArtifact?.url],
["rtk_archive_sha256", rtkArtifact?.sha256],
["rtk_archive_size", rtkArtifact?.sizeBytes],
["rtk_archive_executable", rtkArtifact?.executable],
["rtk_license_url", lock.rtk?.license?.url],
["rtk_license_sha256", lock.rtk?.license?.sha256],
["rtk_license_size", lock.rtk?.license?.sizeBytes],
["configured_node_distribution", lock.platforms[platform].nodeDistribution],
["configured_runner", lock.platforms[platform].runner]
]) {
if (!value) throw new Error(`runtime lock missing ${name}`);
console.log(`${name}=${value}`);
}
NODE
- name: Check matrix matches runtime lock
shell: bash
env:
CONFIGURED_NODE_DISTRIBUTION: ${{ steps.lock.outputs.configured_node_distribution }}
CONFIGURED_RUNNER: ${{ steps.lock.outputs.configured_runner }}
MATRIX_NODE_DISTRIBUTION: ${{ matrix.node_distribution }}
MATRIX_RUNNER: ${{ matrix.runner }}
run: |
if [ "${CONFIGURED_NODE_DISTRIBUTION}" != "${MATRIX_NODE_DISTRIBUTION}" ]; then
echo "Matrix node distribution does not match runtime lock" >&2
exit 1
fi
if [ "${CONFIGURED_RUNNER}" != "${MATRIX_RUNNER}" ]; then
echo "Matrix runner does not match runtime lock" >&2
exit 1
fi
- name: Install build tools
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y rsync zip
- name: Install uv
if: runner.os != 'Windows'
shell: bash
env:
UV_VERSION: ${{ steps.lock.outputs.uv_version }}
run: |
curl --proto '=https' --tlsv1.2 -fsSL "https://releases.astral.sh/github/uv/releases/download/${UV_VERSION}/uv-installer.sh" | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install managed Python
if: runner.os != 'Windows'
shell: bash
env:
PYTHON_VERSION: ${{ steps.lock.outputs.python_version }}
run: |
export UV_PYTHON_INSTALL_DIR="${RUNNER_TEMP}/tutti-runtime-python"
uv python install --no-bin "${PYTHON_VERSION}"
python_root="$(find "${UV_PYTHON_INSTALL_DIR}" -mindepth 1 -maxdepth 1 -type d ! -name '.temp' | sort | head -n 1)"
if [ -z "${python_root}" ] || ! find "${python_root}/bin" -maxdepth 1 -type f -perm -111 -name 'python3*' | grep -q .; then
echo "uv did not install a usable Python runtime" >&2
exit 1
fi
echo "PYTHON_ROOT=${python_root}" >> "$GITHUB_ENV"
- name: Download Node.js
if: runner.os != 'Windows'
shell: bash
env:
NODE_DISTRIBUTION: ${{ matrix.node_distribution }}
NODE_RELEASE_BASE_URL: ${{ steps.lock.outputs.node_release_base_url }}
NODE_VERSION: ${{ steps.lock.outputs.node_version }}
run: |
node_archive="node-v${NODE_VERSION}-${NODE_DISTRIBUTION}.tar.gz"
mkdir -p "${RUNNER_TEMP}/tutti-runtime-node"
curl --retry 3 --retry-delay 2 -fsSLo "${RUNNER_TEMP}/${node_archive}" "${NODE_RELEASE_BASE_URL}/${node_archive}"
curl --retry 3 --retry-delay 2 -fsSLo "${RUNNER_TEMP}/SHASUMS256.txt" "${NODE_RELEASE_BASE_URL}/SHASUMS256.txt"
(cd "${RUNNER_TEMP}" && grep " ${node_archive}$" SHASUMS256.txt | shasum -a 256 -c -)
tar -xzf "${RUNNER_TEMP}/${node_archive}" -C "${RUNNER_TEMP}/tutti-runtime-node" --strip-components=1
if [ ! -x "${RUNNER_TEMP}/tutti-runtime-node/bin/node" ] || [ ! -x "${RUNNER_TEMP}/tutti-runtime-node/bin/npm" ]; then
echo "Node.js archive does not contain node and npm" >&2
exit 1
fi
- name: Download RTK
if: runner.os != 'Windows'
shell: bash
env:
RTK_ARCHIVE_EXECUTABLE: ${{ steps.lock.outputs.rtk_archive_executable }}
RTK_ARCHIVE_SHA256: ${{ steps.lock.outputs.rtk_archive_sha256 }}
RTK_ARCHIVE_SIZE: ${{ steps.lock.outputs.rtk_archive_size }}
RTK_ARCHIVE_URL: ${{ steps.lock.outputs.rtk_archive_url }}
RTK_LICENSE_SHA256: ${{ steps.lock.outputs.rtk_license_sha256 }}
RTK_LICENSE_SIZE: ${{ steps.lock.outputs.rtk_license_size }}
RTK_LICENSE_URL: ${{ steps.lock.outputs.rtk_license_url }}
RTK_VERSION: ${{ steps.lock.outputs.rtk_version }}
run: |
archive="${RUNNER_TEMP}/$(basename "${RTK_ARCHIVE_URL}")"
license="${RUNNER_TEMP}/rtk-LICENSE"
expanded="${RUNNER_TEMP}/tutti-runtime-rtk-expanded"
target="${RUNNER_TEMP}/tutti-runtime-rtk/rtk"
curl --retry 3 --retry-delay 2 -fsSLo "${archive}" "${RTK_ARCHIVE_URL}"
test "$(wc -c < "${archive}" | tr -d ' ')" = "${RTK_ARCHIVE_SIZE}"
printf '%s %s\n' "${RTK_ARCHIVE_SHA256}" "${archive}" | shasum -a 256 -c -
rm -rf "${expanded}" "${target}"
mkdir -p "${expanded}" "${target}/bin"
tar -xf "${archive}" -C "${expanded}"
executable="$(find "${expanded}" -type f -name "${RTK_ARCHIVE_EXECUTABLE}" | head -n 1)"
test -n "${executable}"
cp "${executable}" "${target}/bin/rtk"
chmod 0755 "${target}/bin/rtk"
curl --retry 3 --retry-delay 2 -fsSLo "${license}" "${RTK_LICENSE_URL}"
test "$(wc -c < "${license}" | tr -d ' ')" = "${RTK_LICENSE_SIZE}"
printf '%s %s\n' "${RTK_LICENSE_SHA256}" "${license}" | shasum -a 256 -c -
cp "${license}" "${target}/LICENSE"
"${target}/bin/rtk" --version | grep -Fx "rtk ${RTK_VERSION}"
- name: Assemble runtime artifacts
if: runner.os != 'Windows'
shell: bash
env:
NODE_VERSION: ${{ steps.lock.outputs.node_version }}
PLATFORM: ${{ matrix.platform }}
PYTHON_VERSION: ${{ steps.lock.outputs.python_version }}
RTK_VERSION: ${{ steps.lock.outputs.rtk_version }}
RUNTIME_VERSION: ${{ steps.lock.outputs.runtime_version }}
run: |
artifact_dir="${GITHUB_WORKSPACE}/tutti-app-runtime/${RUNTIME_VERSION}/${PLATFORM}"
python_staging="${RUNNER_TEMP}/tutti-app-runtime-python"
node_staging="${RUNNER_TEMP}/tutti-app-runtime-node-artifact"
rtk_staging="${RUNNER_TEMP}/tutti-runtime-rtk"
python_artifact_dir="${artifact_dir}/python"
node_artifact_dir="${artifact_dir}/node"
rtk_artifact_dir="${artifact_dir}/rtk"
python_artifact_file="tutti-app-runtime-python-${PLATFORM}-${RUNTIME_VERSION}.zip"
node_artifact_file="tutti-app-runtime-node-${PLATFORM}-${RUNTIME_VERSION}.zip"
rtk_artifact_file="tutti-app-runtime-rtk-${PLATFORM}-${RUNTIME_VERSION}.zip"
mkdir -p "${python_staging}/python" "${node_staging}/node" "${python_artifact_dir}" "${node_artifact_dir}" "${rtk_artifact_dir}"
rsync -aL "${PYTHON_ROOT}/" "${python_staging}/python/"
rsync -aL "${RUNNER_TEMP}/tutti-runtime-node/" "${node_staging}/node/"
if [ ! -x "${python_staging}/python/bin/python3" ]; then
python_executable="$(find "${python_staging}/python/bin" -maxdepth 1 -type f -perm -111 -name 'python3*' | sort | head -n 1)"
cp "${python_executable}" "${python_staging}/python/bin/python3"
fi
cat > "${node_staging}/node/bin/npm" <<'SH'
#!/bin/sh
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
exec "${script_dir}/node" "${script_dir}/../lib/node_modules/npm/bin/npm-cli.js" "$@"
SH
cat > "${node_staging}/node/bin/npx" <<'SH'
#!/bin/sh
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
exec "${script_dir}/node" "${script_dir}/../lib/node_modules/npm/bin/npx-cli.js" "$@"
SH
cat > "${node_staging}/node/bin/corepack" <<'SH'
#!/bin/sh
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
exec "${script_dir}/node" "${script_dir}/../lib/node_modules/corepack/dist/corepack.js" "$@"
SH
chmod 0755 \
"${node_staging}/node/bin/npm" \
"${node_staging}/node/bin/npx" \
"${node_staging}/node/bin/corepack"
test -x "${python_staging}/python/bin/python3"
test -x "${node_staging}/node/bin/node"
test -x "${node_staging}/node/bin/npm"
test -x "${node_staging}/node/bin/npx"
test -x "${node_staging}/node/bin/corepack"
test -x "${rtk_staging}/rtk/bin/rtk"
PATH="${node_staging}/node/bin:${PATH}" "${node_staging}/node/bin/npm" --version
PATH="${node_staging}/node/bin:${PATH}" "${node_staging}/node/bin/npx" --version
PATH="${node_staging}/node/bin:${PATH}" "${node_staging}/node/bin/corepack" --version
"${rtk_staging}/rtk/bin/rtk" --version | grep -Fx "rtk ${RTK_VERSION}"
find "${python_staging}" "${node_staging}" "${rtk_staging}" -exec touch -h -t 202606010000 {} +
(cd "${python_staging}" && zip -X -qry "${python_artifact_dir}/${python_artifact_file}" .)
(cd "${node_staging}" && zip -X -qry "${node_artifact_dir}/${node_artifact_file}" .)
(cd "${rtk_staging}" && zip -X -qry "${rtk_artifact_dir}/${rtk_artifact_file}" .)
python_artifact_sha256="$(shasum -a 256 "${python_artifact_dir}/${python_artifact_file}" | awk '{print $1}')"
node_artifact_sha256="$(shasum -a 256 "${node_artifact_dir}/${node_artifact_file}" | awk '{print $1}')"
rtk_artifact_sha256="$(shasum -a 256 "${rtk_artifact_dir}/${rtk_artifact_file}" | awk '{print $1}')"
python_artifact_size="$(wc -c < "${python_artifact_dir}/${python_artifact_file}" | tr -d ' ')"
node_artifact_size="$(wc -c < "${node_artifact_dir}/${node_artifact_file}" | tr -d ' ')"
rtk_artifact_size="$(wc -c < "${rtk_artifact_dir}/${rtk_artifact_file}" | tr -d ' ')"
cat > "${artifact_dir}/metadata.json" <<JSON
{
"schemaVersion": "tutti.app.runtime-platform.v2",
"runtimeVersion": "${RUNTIME_VERSION}",
"platform": "${PLATFORM}",
"pythonVersion": "${PYTHON_VERSION}",
"nodeVersion": "${NODE_VERSION}",
"components": {
"python": {
"version": "${PYTHON_VERSION}",
"artifactPath": "${RUNTIME_VERSION}/${PLATFORM}/python/${python_artifact_file}",
"artifactSha256": "${python_artifact_sha256}",
"artifactSizeBytes": ${python_artifact_size}
},
"node": {
"version": "${NODE_VERSION}",
"artifactPath": "${RUNTIME_VERSION}/${PLATFORM}/node/${node_artifact_file}",
"artifactSha256": "${node_artifact_sha256}",
"artifactSizeBytes": ${node_artifact_size}
},
"rtk": {
"version": "${RTK_VERSION}",
"artifactPath": "${RUNTIME_VERSION}/${PLATFORM}/rtk/${rtk_artifact_file}",
"artifactSha256": "${rtk_artifact_sha256}",
"artifactSizeBytes": ${rtk_artifact_size}
}
},
"profiles": {
"baseline": ["python", "node"],
"node-static": ["node"],
"rtk-saver": ["rtk"]
}
}
JSON
- name: Download Node.js on Windows
if: runner.os == 'Windows'
shell: pwsh
env:
NODE_DISTRIBUTION: ${{ matrix.node_distribution }}
NODE_RELEASE_BASE_URL: ${{ steps.lock.outputs.node_release_base_url }}
NODE_VERSION: ${{ steps.lock.outputs.node_version }}
run: |
$archive = "node-v$env:NODE_VERSION-$env:NODE_DISTRIBUTION.zip"
$archivePath = Join-Path $env:RUNNER_TEMP $archive
$checksumsPath = Join-Path $env:RUNNER_TEMP "SHASUMS256.txt"
Invoke-WebRequest -Uri "$env:NODE_RELEASE_BASE_URL/$archive" -OutFile $archivePath
Invoke-WebRequest -Uri "$env:NODE_RELEASE_BASE_URL/SHASUMS256.txt" -OutFile $checksumsPath
$expected = ((Get-Content $checksumsPath | Where-Object { $_ -match " $([regex]::Escape($archive))$" }) -split '\s+')[0]
$actual = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant()
if (-not $expected -or $actual -ne $expected) {
throw "Node.js archive checksum mismatch"
}
$expanded = Join-Path $env:RUNNER_TEMP "tutti-runtime-node-expanded"
$target = Join-Path $env:RUNNER_TEMP "tutti-runtime-node"
Remove-Item $expanded, $target -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -Path $archivePath -DestinationPath $expanded
$source = Get-ChildItem $expanded -Directory | Select-Object -First 1
New-Item -ItemType Directory -Path $target | Out-Null
Copy-Item (Join-Path $source.FullName '*') $target -Recurse
if (-not (Test-Path (Join-Path $target 'node.exe')) -or -not (Test-Path (Join-Path $target 'npm.cmd'))) {
throw "Node.js archive does not contain node.exe and npm.cmd"
}
- name: Download managed Python on Windows
if: runner.os == 'Windows'
shell: pwsh
env:
PYTHON_VERSION: ${{ steps.lock.outputs.python_version }}
PYTHON_WINDOWS_ARCHIVE_URL: ${{ steps.lock.outputs.python_windows_archive_url }}
PYTHON_WINDOWS_ARCHIVE_SHA256: ${{ steps.lock.outputs.python_windows_archive_sha256 }}
PYTHON_WINDOWS_ARCHIVE_ROOT: ${{ steps.lock.outputs.python_windows_archive_root }}
run: |
# Windows uses the last official CPython 3.12 embeddable release so
# every executable module carries the PSF Authenticode signature.
$archive = "python-$env:PYTHON_VERSION-embed-amd64.zip"
$archivePath = Join-Path $env:RUNNER_TEMP $archive
Invoke-WebRequest -Uri $env:PYTHON_WINDOWS_ARCHIVE_URL -OutFile $archivePath
$actual = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant()
if ($actual -ne $env:PYTHON_WINDOWS_ARCHIVE_SHA256) {
throw "Managed Python archive checksum mismatch"
}
$expanded = Join-Path $env:RUNNER_TEMP 'tutti-runtime-python-expanded'
$target = Join-Path $env:RUNNER_TEMP 'tutti-runtime-python'
Remove-Item $expanded, $target -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $expanded -Force | Out-Null
Expand-Archive -LiteralPath $archivePath -DestinationPath $expanded
$pythonRoot = Join-Path $target 'python/bin'
New-Item -ItemType Directory -Path $pythonRoot -Force | Out-Null
$sourceRoot = Join-Path $expanded $env:PYTHON_WINDOWS_ARCHIVE_ROOT
if (-not (Test-Path (Join-Path $sourceRoot 'python.exe'))) {
throw "Managed Python archive root does not contain python.exe"
}
$peFiles = Get-ChildItem -LiteralPath $sourceRoot -Recurse -File | Where-Object {
$_.Extension -in '.exe', '.dll', '.pyd'
}
if (-not $peFiles) {
throw "Managed Python archive contains no Windows executable files"
}
$invalidSignatures = @($peFiles | Where-Object {
(Get-AuthenticodeSignature -LiteralPath $_.FullName).Status -ne 'Valid'
})
if ($invalidSignatures.Count -gt 0) {
$invalidNames = ($invalidSignatures.Name | Sort-Object) -join ', '
throw "Managed Python archive contains invalid or unsigned files: $invalidNames"
}
$pythonSignature = Get-AuthenticodeSignature -LiteralPath (Join-Path $sourceRoot 'python.exe')
if ($pythonSignature.SignerCertificate.Subject -notmatch 'O=Python Software Foundation') {
throw "Managed Python executable is not signed by the Python Software Foundation"
}
Copy-Item (Join-Path $sourceRoot '*') $pythonRoot -Recurse
$python = Join-Path $pythonRoot 'python.exe'
if (-not (Test-Path $python)) {
throw "Managed Python archive does not contain python.exe"
}
& $python --version
- name: Download RTK on Windows
if: runner.os == 'Windows'
shell: pwsh
env:
RTK_ARCHIVE_EXECUTABLE: ${{ steps.lock.outputs.rtk_archive_executable }}
RTK_ARCHIVE_SHA256: ${{ steps.lock.outputs.rtk_archive_sha256 }}
RTK_ARCHIVE_SIZE: ${{ steps.lock.outputs.rtk_archive_size }}
RTK_ARCHIVE_URL: ${{ steps.lock.outputs.rtk_archive_url }}
RTK_LICENSE_SHA256: ${{ steps.lock.outputs.rtk_license_sha256 }}
RTK_LICENSE_SIZE: ${{ steps.lock.outputs.rtk_license_size }}
RTK_LICENSE_URL: ${{ steps.lock.outputs.rtk_license_url }}
RTK_VERSION: ${{ steps.lock.outputs.rtk_version }}
run: |
$archive = Join-Path $env:RUNNER_TEMP ([System.IO.Path]::GetFileName($env:RTK_ARCHIVE_URL))
$license = Join-Path $env:RUNNER_TEMP 'rtk-LICENSE'
$expanded = Join-Path $env:RUNNER_TEMP 'tutti-runtime-rtk-expanded'
$target = Join-Path $env:RUNNER_TEMP 'tutti-runtime-rtk/rtk'
Invoke-WebRequest -Uri $env:RTK_ARCHIVE_URL -OutFile $archive
if ((Get-Item $archive).Length -ne [int64]$env:RTK_ARCHIVE_SIZE) {
throw 'RTK archive size mismatch'
}
if ((Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() -ne $env:RTK_ARCHIVE_SHA256) {
throw 'RTK archive checksum mismatch'
}
Remove-Item $expanded, $target -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -LiteralPath $archive -DestinationPath $expanded
New-Item -ItemType Directory -Path (Join-Path $target 'bin') -Force | Out-Null
$source = Get-ChildItem -LiteralPath $expanded -Recurse -File -Filter $env:RTK_ARCHIVE_EXECUTABLE | Select-Object -First 1
if (-not $source) { throw "RTK archive does not contain $env:RTK_ARCHIVE_EXECUTABLE" }
Copy-Item $source.FullName (Join-Path $target 'bin/rtk.exe')
Invoke-WebRequest -Uri $env:RTK_LICENSE_URL -OutFile $license
if ((Get-Item $license).Length -ne [int64]$env:RTK_LICENSE_SIZE) {
throw 'RTK license size mismatch'
}
if ((Get-FileHash -Algorithm SHA256 $license).Hash.ToLowerInvariant() -ne $env:RTK_LICENSE_SHA256) {
throw 'RTK license checksum mismatch'
}
Copy-Item $license (Join-Path $target 'LICENSE')
if ((& (Join-Path $target 'bin/rtk.exe') --version) -ne "rtk $env:RTK_VERSION") {
throw 'RTK executable version mismatch'
}
- name: Assemble Windows runtime artifacts
if: runner.os == 'Windows'
shell: pwsh
env:
NODE_VERSION: ${{ steps.lock.outputs.node_version }}
PYTHON_VERSION: ${{ steps.lock.outputs.python_version }}
RTK_VERSION: ${{ steps.lock.outputs.rtk_version }}
PLATFORM: ${{ matrix.platform }}
RUNTIME_VERSION: ${{ steps.lock.outputs.runtime_version }}
run: |
$artifactDir = Join-Path $env:GITHUB_WORKSPACE "tutti-app-runtime/$env:RUNTIME_VERSION/$env:PLATFORM"
$pythonArtifactDir = Join-Path $artifactDir 'python'
$nodeArtifactDir = Join-Path $artifactDir "node"
$rtkArtifactDir = Join-Path $artifactDir 'rtk'
$pythonStaging = Join-Path $env:RUNNER_TEMP 'tutti-runtime-python-artifact'
$nodeStaging = Join-Path $env:RUNNER_TEMP "tutti-app-runtime-node-artifact"
$rtkStaging = Join-Path $env:RUNNER_TEMP 'tutti-runtime-rtk'
$pythonRoot = Join-Path $pythonStaging 'python/bin'
$nodeRoot = Join-Path $nodeStaging "node/bin"
$pythonArtifactFile = "tutti-app-runtime-python-$env:PLATFORM-$env:RUNTIME_VERSION.zip"
$artifactFile = "tutti-app-runtime-node-$env:PLATFORM-$env:RUNTIME_VERSION.zip"
$rtkArtifactFile = "tutti-app-runtime-rtk-$env:PLATFORM-$env:RUNTIME_VERSION.zip"
$pythonArtifactPath = Join-Path $pythonArtifactDir $pythonArtifactFile
$artifactPath = Join-Path $nodeArtifactDir $artifactFile
$rtkArtifactPath = Join-Path $rtkArtifactDir $rtkArtifactFile
Remove-Item $pythonStaging, $nodeStaging -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $pythonRoot, $pythonArtifactDir, $nodeRoot, $nodeArtifactDir, $rtkArtifactDir -Force | Out-Null
Copy-Item (Join-Path $env:RUNNER_TEMP 'tutti-runtime-python/python/bin/*') $pythonRoot -Recurse
Copy-Item (Join-Path $env:RUNNER_TEMP 'tutti-runtime-node/*') $nodeRoot -Recurse
& (Join-Path $pythonRoot 'python.exe') --version
& (Join-Path $nodeRoot 'node.exe') --version
& (Join-Path $nodeRoot 'npm.cmd') --version
& (Join-Path $nodeRoot 'corepack.cmd') --version
if ((& (Join-Path $rtkStaging 'rtk/bin/rtk.exe') --version) -ne "rtk $env:RTK_VERSION") {
throw 'RTK executable version mismatch during artifact assembly'
}
Compress-Archive -Path (Join-Path $pythonStaging '*') -DestinationPath $pythonArtifactPath -Force
Compress-Archive -Path (Join-Path $nodeStaging '*') -DestinationPath $artifactPath -Force
Compress-Archive -Path (Join-Path $rtkStaging '*') -DestinationPath $rtkArtifactPath -Force
$pythonSha256 = (Get-FileHash -Algorithm SHA256 $pythonArtifactPath).Hash.ToLowerInvariant()
$pythonSize = (Get-Item $pythonArtifactPath).Length
$sha256 = (Get-FileHash -Algorithm SHA256 $artifactPath).Hash.ToLowerInvariant()
$size = (Get-Item $artifactPath).Length
$rtkSha256 = (Get-FileHash -Algorithm SHA256 $rtkArtifactPath).Hash.ToLowerInvariant()
$rtkSize = (Get-Item $rtkArtifactPath).Length
$metadata = [ordered]@{
schemaVersion = 'tutti.app.runtime-platform.v2'
runtimeVersion = $env:RUNTIME_VERSION
platform = $env:PLATFORM
pythonVersion = $env:PYTHON_VERSION
nodeVersion = $env:NODE_VERSION
components = [ordered]@{
python = [ordered]@{
version = $env:PYTHON_VERSION
artifactPath = "$env:RUNTIME_VERSION/$env:PLATFORM/python/$pythonArtifactFile"
artifactSha256 = $pythonSha256
artifactSizeBytes = $pythonSize
}
node = [ordered]@{
version = $env:NODE_VERSION
artifactPath = "$env:RUNTIME_VERSION/$env:PLATFORM/node/$artifactFile"
artifactSha256 = $sha256
artifactSizeBytes = $size
}
rtk = [ordered]@{
version = $env:RTK_VERSION
artifactPath = "$env:RUNTIME_VERSION/$env:PLATFORM/rtk/$rtkArtifactFile"
artifactSha256 = $rtkSha256
artifactSizeBytes = $rtkSize
}
}
profiles = [ordered]@{
baseline = @('python', 'node')
'node-static' = @('node')
'rtk-saver' = @('rtk')
}
}
$metadata | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $artifactDir 'metadata.json') -Encoding utf8
- name: Upload runtime artifact
uses: actions/upload-artifact@v7
with:
name: tutti-app-runtime-${{ matrix.platform }}
path: tutti-app-runtime/${{ steps.lock.outputs.runtime_version }}/${{ matrix.platform }}
if-no-files-found: error
publish:
name: Publish runtime catalog
runs-on: ubuntu-latest
needs: build
env:
AWS_REGION_VALUE: ${{ inputs.aws_region || vars.TUTTI_APP_RUNTIME_AWS_REGION || vars.TUTTI_APP_RELEASES_AWS_REGION }}
AWS_ROLE_ARN_VALUE: ${{ inputs.aws_role_arn || vars.TUTTI_APP_RUNTIME_AWS_ROLE_ARN || vars.TUTTI_APP_RELEASES_AWS_ROLE_ARN }}
S3_BUCKET_VALUE: ${{ inputs.s3_bucket || vars.TUTTI_APP_RUNTIME_S3_BUCKET || vars.TUTTI_APP_RELEASES_S3_BUCKET }}
S3_PREFIX_VALUE: ${{ inputs.s3_prefix || vars.TUTTI_APP_RUNTIME_S3_PREFIX || 'tutti-app-runtimes' }}
ARTIFACT_BASE_URL_VALUE: ${{ inputs.artifact_base_url || vars.TUTTI_APP_RUNTIME_ARTIFACT_BASE_URL }}
CLOUDFRONT_DISTRIBUTION_ID_VALUE: ${{ inputs.cloudfront_distribution_id || vars.TUTTI_APP_RUNTIME_CLOUDFRONT_DISTRIBUTION_ID }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .node-version
- name: Download runtime artifacts
uses: actions/download-artifact@v4
with:
pattern: tutti-app-runtime-*
path: downloaded-tutti-app-runtime
merge-multiple: false
- name: Restore runtime artifact layout
shell: bash
run: |
rm -rf tutti-app-runtime
mkdir -p tutti-app-runtime
while IFS= read -r metadata_file; do
runtime_version="$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).runtimeVersion)" "${metadata_file}")"
platform="$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).platform)" "${metadata_file}")"
target_dir="tutti-app-runtime/${runtime_version}/${platform}"
mkdir -p "${target_dir}"
cp -R "$(dirname "${metadata_file}")/." "${target_dir}/"
done < <(find downloaded-tutti-app-runtime -name metadata.json -type f | sort)
metadata_count="$(find tutti-app-runtime -name metadata.json -type f | wc -l | tr -d ' ')"
if [ "${metadata_count}" -eq 0 ]; then
echo "No runtime metadata files were downloaded" >&2
exit 1
fi
echo "Restored ${metadata_count} runtime artifact metadata files."
- name: Validate AWS configuration
shell: bash
run: |
missing=()
for name in AWS_REGION_VALUE AWS_ROLE_ARN_VALUE S3_BUCKET_VALUE ARTIFACT_BASE_URL_VALUE; do
if [ -z "${!name:-}" ]; then
missing+=("${name}")
fi
done
if [ "${#missing[@]}" -gt 0 ]; then
printf 'Missing required AWS configuration: %s\n' "${missing[*]}" >&2
exit 1
fi
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ env.AWS_REGION_VALUE }}
role-to-assume: ${{ env.AWS_ROLE_ARN_VALUE }}
- name: Build runtime catalog
shell: bash
run: |
metadata_args=()
while IFS= read -r metadata_file; do
metadata_args+=(--metadata-file "${metadata_file}")
done < <(find tutti-app-runtime -name metadata.json -type f | sort)
if [ "${#metadata_args[@]}" -eq 0 ]; then
echo "No runtime metadata files were downloaded" >&2
exit 1
fi
node tools/scripts/build-tutti-app-runtime-catalog.mjs \
--artifact-base-url "${ARTIFACT_BASE_URL_VALUE%/}" \
"${metadata_args[@]}" \
--output tutti-app-runtime/catalog.json
- name: Upload runtime artifacts to S3
shell: bash
run: |
if [ -z "${S3_BUCKET_VALUE}" ]; then
echo "S3 bucket is required" >&2
exit 1
fi
prefix="${S3_PREFIX_VALUE%/}"
if [ -n "${prefix}" ]; then
s3_root="s3://${S3_BUCKET_VALUE}/${prefix}"
else
s3_root="s3://${S3_BUCKET_VALUE}"
fi
find tutti-app-runtime -name 'tutti-app-runtime-*-*.zip' -type f | sort | while IFS= read -r artifact; do
relative_path="${artifact#tutti-app-runtime/}"
aws s3 cp "${artifact}" "${s3_root}/${relative_path}" \
--content-type "application/zip" \
--cache-control "public, max-age=31536000, immutable"
done
- name: Upload runtime catalog to S3
shell: bash
run: |
prefix="${S3_PREFIX_VALUE%/}"
if [ -n "${prefix}" ]; then
s3_target="s3://${S3_BUCKET_VALUE}/${prefix}/catalog.json"
else
s3_target="s3://${S3_BUCKET_VALUE}/catalog.json"
fi
aws s3 cp tutti-app-runtime/catalog.json "${s3_target}" \
--content-type "application/json" \
--cache-control "public, max-age=60"
- name: Invalidate CloudFront runtime catalog
if: env.CLOUDFRONT_DISTRIBUTION_ID_VALUE != ''
shell: bash
run: |
prefix="${S3_PREFIX_VALUE%/}"
if [ -n "${prefix}" ]; then
path_to_invalidate="/${prefix}/catalog.json"
else
path_to_invalidate="/catalog.json"
fi
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID_VALUE}" \
--paths "${path_to_invalidate}"