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
661 lines
32 KiB
YAML
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}"
|