chore(deploy): add repeatable application release wrapper
This commit is contained in:
1 parent
086e78a542
commit
0340408483
3 files changed
+545
No files matched your search
@@ -0,0 +1,316 @@
|
||||
#!/bin/bash
|
||||
set -Eeuo pipefail
|
||||
umask 022
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
printf 'usage: SCREENER_PUBLIC_ORIGIN=https://share.example.com %s /opt/screener/uploads/<release>.release.json\n' "$0" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
node='/usr/local/bin/node'
|
||||
npm='/usr/local/bin/npm'
|
||||
upload_root='/opt/screener/uploads'
|
||||
release_root='/opt/screener/releases'
|
||||
current='/opt/screener/current'
|
||||
lock='/opt/screener/deploy.lock'
|
||||
public_origin="${SCREENER_PUBLIC_ORIGIN:-}"
|
||||
descriptor="$(realpath -e -- "$1")"
|
||||
stage=''
|
||||
cutover_started=0
|
||||
old_release=''
|
||||
|
||||
test "$(dirname -- "$descriptor")" = "$upload_root"
|
||||
[[ "$public_origin" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?$ ]]
|
||||
|
||||
IFS=$'\t' read -r revision release_id artifact_name artifact_sha manifest_name manifest_sha file_count main_asset < <(
|
||||
"$node" --input-type=module --eval '
|
||||
import { readFileSync } from "node:fs";
|
||||
const value = JSON.parse(readFileSync(process.argv[1], "utf8"));
|
||||
const fields = [
|
||||
value.revision,
|
||||
value.releaseId,
|
||||
value.artifact,
|
||||
value.artifactSha256,
|
||||
value.manifest,
|
||||
value.manifestSha256,
|
||||
value.fileCount,
|
||||
value.mainAsset,
|
||||
];
|
||||
if (value.schema !== 1 || fields.some((field) =>
|
||||
!["string", "number"].includes(typeof field) || String(field).includes("\t") || String(field).includes("\n")
|
||||
)) process.exit(2);
|
||||
process.stdout.write(fields.join("\t") + "\n");
|
||||
' "$descriptor"
|
||||
)
|
||||
|
||||
[[ "$revision" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$release_id" =~ ^[0-9a-f]{7}$ ]]
|
||||
test "$release_id" = "${revision:0:7}"
|
||||
test "$artifact_name" = "screener-${release_id}-runtime.tar.gz"
|
||||
test "$manifest_name" = "screener-${release_id}.manifest.tsv"
|
||||
[[ "$artifact_sha" =~ ^[0-9a-f]{64}$ ]]
|
||||
[[ "$manifest_sha" =~ ^[0-9a-f]{64}$ ]]
|
||||
[[ "$file_count" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "$main_asset" =~ ^assets/index-[A-Za-z0-9_-]+\.js$ ]]
|
||||
|
||||
artifact="${upload_root}/${artifact_name}"
|
||||
manifest="${upload_root}/${manifest_name}"
|
||||
release="${release_root}/${release_id}"
|
||||
|
||||
cleanup_stage() {
|
||||
if [ -z "$stage" ] || [ ! -e "$stage" ]; then
|
||||
return
|
||||
fi
|
||||
local resolved parent base
|
||||
resolved="$(realpath -m -- "$stage")"
|
||||
parent="$(dirname -- "$resolved")"
|
||||
base="$(basename -- "$resolved")"
|
||||
if [ "$parent" != "$release_root" ] || [[ "$base" != ".${release_id}."* ]]; then
|
||||
printf 'refusing to remove unexpected stage: %s\n' "$resolved" >&2
|
||||
return
|
||||
fi
|
||||
rm -rf --one-file-system -- "$resolved"
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local body=''
|
||||
for _ in $(seq 1 120); do
|
||||
body="$(curl -fsS --max-time 2 http://127.0.0.1:8787/healthz 2>/dev/null || true)"
|
||||
if [ "$body" = '{"status":"ok"}' ]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
recover() {
|
||||
local code="${1:-$?}" ok=1 link=''
|
||||
trap - ERR HUP INT TERM EXIT
|
||||
set +e
|
||||
if [ "$cutover_started" -eq 1 ]; then
|
||||
systemctl stop screener.service || ok=0
|
||||
if [ "$(readlink -f -- "$current" 2>/dev/null)" != "$old_release" ]; then
|
||||
link="${current}.recover-${release_id}-$$"
|
||||
if [ -e "$link" ] || [ -L "$link" ]; then
|
||||
ok=0
|
||||
elif ! ln -s "$old_release" "$link" || ! mv -Tf "$link" "$current"; then
|
||||
ok=0
|
||||
fi
|
||||
fi
|
||||
test "$(readlink -f -- "$current" 2>/dev/null)" = "$old_release" || ok=0
|
||||
systemctl start screener.service || ok=0
|
||||
wait_for_health || ok=0
|
||||
fi
|
||||
cleanup_stage
|
||||
printf 'deployment_failed=%s recovery_ok=%s current=%s active=%s\n' \
|
||||
"$code" "$ok" \
|
||||
"$(readlink -f -- "$current" 2>/dev/null || true)" \
|
||||
"$(systemctl show screener.service -p ActiveState --value 2>/dev/null || true)" >&2
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
exit 90
|
||||
fi
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
trap 'recover $?' ERR
|
||||
trap 'recover 129' HUP
|
||||
trap 'recover 130' INT
|
||||
trap 'recover 143' TERM
|
||||
trap 'code=$?; if [ "$code" -ne 0 ]; then recover "$code"; fi' EXIT
|
||||
|
||||
exec 9>"$lock"
|
||||
flock -n 9
|
||||
test -x "$node"
|
||||
test -x "$npm"
|
||||
old_release="$(readlink -f -- "$current")"
|
||||
test "$(dirname -- "$old_release")" = "$release_root"
|
||||
test -d "$old_release"
|
||||
test ! -L "$old_release"
|
||||
test ! -e "$release"
|
||||
test -f "$artifact"
|
||||
test -f "$manifest"
|
||||
test "$(sha256sum "$artifact" | awk '{print $1}')" = "$artifact_sha"
|
||||
test "$(sha256sum "$manifest" | awk '{print $1}')" = "$manifest_sha"
|
||||
for service in screener livekit coturn nginx; do
|
||||
test "$(systemctl show "${service}.service" -p ActiveState --value)" = 'active'
|
||||
test "$(systemctl show "${service}.service" -p NRestarts --value)" = '0'
|
||||
done
|
||||
|
||||
while IFS= read -r raw_entry; do
|
||||
entry="$raw_entry"
|
||||
while [[ "$entry" = ./* ]]; do entry="${entry#./}"; done
|
||||
entry="${entry%/}"
|
||||
if [ -z "$entry" ]; then continue; fi
|
||||
case "/$entry/" in
|
||||
*'/../'*|*'/./'*|*'//'*) printf 'invalid archive entry: %s\n' "$raw_entry" >&2; exit 41 ;;
|
||||
esac
|
||||
case "$entry" in
|
||||
/*|*\\*) printf 'invalid archive entry: %s\n' "$raw_entry" >&2; exit 41 ;;
|
||||
REVISION|package.json|package-lock.json|dist|dist/client|dist/server|dist/client/*|dist/server/*) ;;
|
||||
*) printf 'unexpected archive entry: %s\n' "$raw_entry" >&2; exit 42 ;;
|
||||
esac
|
||||
done < <(tar -tzf "$artifact")
|
||||
|
||||
stage="$(mktemp -d "${release_root}/.${release_id}.XXXXXX")"
|
||||
test "$(dirname -- "$(realpath -m -- "$stage")")" = "$release_root"
|
||||
chmod 0755 "$stage"
|
||||
tar --no-same-owner --no-same-permissions -xzf "$artifact" -C "$stage"
|
||||
|
||||
STAGE="$stage" MANIFEST="$manifest" FILE_COUNT="$file_count" REVISION="$revision" "$node" --input-type=module <<'NODE'
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstat, readFile, readdir } from 'node:fs/promises';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
|
||||
const stage = process.env.STAGE;
|
||||
const text = await readFile(process.env.MANIFEST, 'utf8');
|
||||
if (!text.endsWith('\n')) process.exit(61);
|
||||
const lines = text.slice(0, -1).split('\n');
|
||||
const expected = new Map();
|
||||
for (const line of lines) {
|
||||
const fields = line.split('\t');
|
||||
if (fields.length !== 3 || !/^[0-9a-f]{64}$/.test(fields[0]) || !/^\d+$/.test(fields[1])) process.exit(62);
|
||||
const path = fields[2];
|
||||
if (!/^(REVISION|package(-lock)?\.json|dist\/(client|server)\/[A-Za-z0-9._/-]+)$/.test(path)) process.exit(63);
|
||||
if (path.split('/').some((part) => part === '' || part === '.' || part === '..') || expected.has(path)) process.exit(64);
|
||||
expected.set(path, { hash: fields[0], size: Number(fields[1]) });
|
||||
}
|
||||
if (expected.size !== Number(process.env.FILE_COUNT)) process.exit(65);
|
||||
|
||||
const actual = new Map();
|
||||
const identities = new Set();
|
||||
const pending = [stage];
|
||||
while (pending.length) {
|
||||
const directory = pending.pop();
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const absolute = join(directory, entry.name);
|
||||
const metadata = await lstat(absolute);
|
||||
if (metadata.isSymbolicLink()) process.exit(66);
|
||||
if (metadata.isDirectory()) {
|
||||
pending.push(absolute);
|
||||
} else if (metadata.isFile()) {
|
||||
const identity = `${metadata.dev}:${metadata.ino}`;
|
||||
if (identities.has(identity)) process.exit(67);
|
||||
identities.add(identity);
|
||||
const path = relative(stage, absolute).split(sep).join('/');
|
||||
const body = await readFile(absolute);
|
||||
actual.set(path, { hash: createHash('sha256').update(body).digest('hex'), size: body.length });
|
||||
} else process.exit(68);
|
||||
}
|
||||
}
|
||||
if (actual.size !== expected.size) process.exit(69);
|
||||
for (const [path, record] of expected) {
|
||||
const found = actual.get(path);
|
||||
if (!found || found.hash !== record.hash || found.size !== record.size) process.exit(70);
|
||||
}
|
||||
if ((await readFile(join(stage, 'REVISION'), 'ascii')) !== `${process.env.REVISION}\n`) process.exit(71);
|
||||
console.log(`artifact_manifest=ok files=${actual.size} revision=${process.env.REVISION}`);
|
||||
NODE
|
||||
|
||||
test -f "$stage/dist/client/$main_asset"
|
||||
test -f "$stage/dist/server/server/index.js"
|
||||
find "$stage/dist" -type d -exec chmod 0755 {} +
|
||||
find "$stage/dist" -type f -exec chmod 0644 {} +
|
||||
chmod 0644 "$stage/REVISION" "$stage/package.json" "$stage/package-lock.json"
|
||||
|
||||
systemd-run \
|
||||
--unit="screener-deps-${release_id}" \
|
||||
--wait \
|
||||
--collect \
|
||||
--quiet \
|
||||
--service-type=exec \
|
||||
--property="WorkingDirectory=$stage" \
|
||||
--property='CPUQuota=30%' \
|
||||
--property='MemoryHigh=160M' \
|
||||
--property='MemoryMax=256M' \
|
||||
--property='MemorySwapMax=0' \
|
||||
--property='RuntimeMaxSec=120s' \
|
||||
"$npm" ci --omit=dev --ignore-scripts --no-audit --no-fund
|
||||
|
||||
previous_directory="$PWD"
|
||||
cd "$stage"
|
||||
sudo -u screener "$node" --input-type=module --eval "await Promise.all([import('sirv'), import('ws'), import('zod'), import('livekit-server-sdk'), import('./dist/server/server/config.js')]);"
|
||||
"$node" --env-file=/etc/screener/screener.env --input-type=module --eval "import { loadConfig } from './dist/server/server/config.js'; loadConfig(process.env);"
|
||||
cd "$previous_directory"
|
||||
|
||||
OLD_RELEASE="$old_release" NEW_RELEASE="$stage" "$node" --input-type=module <<'NODE'
|
||||
import { lstatSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
function collect(root) {
|
||||
const identities = new Set();
|
||||
const pending = [root];
|
||||
while (pending.length) {
|
||||
const directory = pending.pop();
|
||||
for (const name of readdirSync(directory)) {
|
||||
const path = join(directory, name);
|
||||
const metadata = lstatSync(path);
|
||||
if (metadata.isSymbolicLink()) continue;
|
||||
if (metadata.isDirectory()) pending.push(path);
|
||||
else if (metadata.isFile()) identities.add(`${metadata.dev}:${metadata.ino}`);
|
||||
}
|
||||
}
|
||||
return identities;
|
||||
}
|
||||
const oldFiles = collect(process.env.OLD_RELEASE);
|
||||
const newFiles = collect(process.env.NEW_RELEASE);
|
||||
let intersection = 0;
|
||||
for (const identity of newFiles) if (oldFiles.has(identity)) intersection += 1;
|
||||
if (intersection !== 0) process.exit(80);
|
||||
console.log(`release_inode_intersection=0 new_regular_files=${newFiles.size}`);
|
||||
NODE
|
||||
|
||||
old_asset="$(OLD_RELEASE="$old_release" "$node" --input-type=module --eval '
|
||||
import { readFileSync } from "node:fs";
|
||||
const html = readFileSync(`${process.env.OLD_RELEASE}/dist/client/index.html`, "utf8");
|
||||
const match = html.match(/<script[^>]+src="\/(assets\/index-[A-Za-z0-9_-]+\.js)"/);
|
||||
if (!match) process.exit(2);
|
||||
process.stdout.write(match[1]);
|
||||
')"
|
||||
|
||||
mv -- "$stage" "$release"
|
||||
stage=''
|
||||
test -d "$release"
|
||||
test ! -L "$release"
|
||||
test "$(stat -c '%U:%G %a' "$release")" = 'root:root 755'
|
||||
|
||||
firewall_before="$(nft list table inet bonfire_filter | sha256sum | awk '{print $1}')"
|
||||
livekit_restarts="$(systemctl show livekit.service -p NRestarts --value)"
|
||||
coturn_restarts="$(systemctl show coturn.service -p NRestarts --value)"
|
||||
nginx_restarts="$(systemctl show nginx.service -p NRestarts --value)"
|
||||
cutover_since="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
cutover_start="$(date +%s%3N)"
|
||||
cutover_started=1
|
||||
systemctl stop screener.service
|
||||
test "$(systemctl show screener.service -p ActiveState --value)" = 'inactive'
|
||||
ln -s "$release" "${current}.${release_id}-$$"
|
||||
mv -Tf "${current}.${release_id}-$$" "$current"
|
||||
test "$(readlink -f -- "$current")" = "$release"
|
||||
systemctl start screener.service
|
||||
wait_for_health
|
||||
health_ready="$(date +%s%3N)"
|
||||
|
||||
test "$(systemctl show screener.service -p ActiveState --value)" = 'active'
|
||||
test "$(systemctl show screener.service -p NRestarts --value)" = '0'
|
||||
test "$(systemctl show livekit.service -p NRestarts --value)" = "$livekit_restarts"
|
||||
test "$(systemctl show coturn.service -p NRestarts --value)" = "$coturn_restarts"
|
||||
test "$(systemctl show nginx.service -p NRestarts --value)" = "$nginx_restarts"
|
||||
test "$(nft list table inet bonfire_filter | sha256sum | awk '{print $1}')" = "$firewall_before"
|
||||
test "$(readlink -f -- "$current")" = "$release"
|
||||
pid="$(systemctl show screener.service -p MainPID --value)"
|
||||
test "$pid" -gt 1
|
||||
test "$(readlink -f "/proc/${pid}/cwd")" = "$release"
|
||||
test "$(curl -fsS --max-time 5 http://127.0.0.1:8787/healthz)" = '{"status":"ok"}'
|
||||
test "$(curl -fsS --max-time 8 "$public_origin/healthz")" = '{"status":"ok"}'
|
||||
curl -fsS --max-time 5 http://127.0.0.1:8787/ | grep -Fq "$main_asset"
|
||||
curl -fsS --max-time 8 "$public_origin/" | grep -Fq "$main_asset"
|
||||
test "$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 "$public_origin/$main_asset")" = '200'
|
||||
if [ "$old_asset" != "$main_asset" ]; then
|
||||
test "$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 "$public_origin/$old_asset")" = '404'
|
||||
fi
|
||||
test -z "$(journalctl -u screener.service --since "$cutover_since" -p warning --no-pager --output=cat)"
|
||||
|
||||
asset_sha="$(sha256sum "$release/dist/client/$main_asset" | awk '{print $1}')"
|
||||
cutover_started=0
|
||||
trap - ERR HUP INT TERM EXIT
|
||||
printf 'deployment=ok revision=%s release=%s previous=%s artifact_sha=%s manifest_sha=%s asset=%s asset_sha=%s health_ms=%s firewall_sha=%s\n' \
|
||||
"$revision" "$release_id" "$old_release" "$artifact_sha" "$manifest_sha" "$main_asset" "$asset_sha" "$((health_ready - cutover_start))" "$firewall_before"
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function run(command, args, cwd) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
fail(`${command} failed: ${(result.stderr || result.stdout).trim()}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function sha256(path) {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
|
||||
function normalizedRelative(root, path) {
|
||||
const value = relative(root, path).split(sep).join("/");
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value.startsWith("../") ||
|
||||
value.includes("\\") ||
|
||||
value.split("/").some((part) => part === "" || part === "." || part === "..")
|
||||
) {
|
||||
fail(`Invalid runtime path: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function runtimePathAllowed(path) {
|
||||
return (
|
||||
path === "REVISION" ||
|
||||
path === "package.json" ||
|
||||
path === "package-lock.json" ||
|
||||
path.startsWith("dist/client/") ||
|
||||
path.startsWith("dist/server/")
|
||||
);
|
||||
}
|
||||
|
||||
function regularFiles(root) {
|
||||
const files = [];
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = join(directory, entry.name);
|
||||
const metadata = lstatSync(absolute);
|
||||
if (metadata.isSymbolicLink()) fail(`Runtime contains a link: ${absolute}`);
|
||||
if (metadata.isDirectory()) {
|
||||
pending.push(absolute);
|
||||
} else if (metadata.isFile()) {
|
||||
files.push(absolute);
|
||||
} else {
|
||||
fail(`Runtime contains an unsupported file type: ${absolute}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files.sort((left, right) =>
|
||||
normalizedRelative(root, left).localeCompare(normalizedRelative(root, right)),
|
||||
);
|
||||
}
|
||||
|
||||
function copyRuntimeFile(sourceRoot, targetRoot, source) {
|
||||
const path = normalizedRelative(sourceRoot, source);
|
||||
if (!runtimePathAllowed(path)) fail(`Unexpected runtime file: ${path}`);
|
||||
const target = join(targetRoot, ...path.split("/"));
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
copyFileSync(source, target);
|
||||
}
|
||||
|
||||
function recordsFor(root) {
|
||||
return regularFiles(root).map((absolute) => {
|
||||
const path = normalizedRelative(root, absolute);
|
||||
if (!runtimePathAllowed(path)) fail(`Unexpected packaged file: ${path}`);
|
||||
return { path, size: statSync(absolute).size, sha256: sha256(absolute) };
|
||||
});
|
||||
}
|
||||
|
||||
function compareRecords(expected, actual) {
|
||||
if (expected.length !== actual.length) fail("Extracted runtime file count differs");
|
||||
for (let index = 0; index < expected.length; index += 1) {
|
||||
const left = expected[index];
|
||||
const right = actual[index];
|
||||
if (
|
||||
left.path !== right.path ||
|
||||
left.size !== right.size ||
|
||||
left.sha256 !== right.sha256
|
||||
) {
|
||||
fail(`Extracted runtime differs at ${left.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateArchiveEntries(entries) {
|
||||
for (const rawEntry of entries.split(/\r?\n/)) {
|
||||
let entry = rawEntry;
|
||||
while (entry.startsWith("./")) entry = entry.slice(2);
|
||||
entry = entry.replace(/\/$/, "");
|
||||
if (entry.length === 0) continue;
|
||||
if (
|
||||
entry.startsWith("/") ||
|
||||
entry.includes("\\") ||
|
||||
entry.split("/").some((part) => part === "" || part === "." || part === "..")
|
||||
) {
|
||||
fail(`Invalid archive entry: ${rawEntry}`);
|
||||
}
|
||||
if (
|
||||
entry !== "REVISION" &&
|
||||
entry !== "package.json" &&
|
||||
entry !== "package-lock.json" &&
|
||||
entry !== "dist" &&
|
||||
!entry.startsWith("dist/client") &&
|
||||
!entry.startsWith("dist/server")
|
||||
) {
|
||||
fail(`Unexpected archive entry: ${rawEntry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.length !== 3) {
|
||||
fail("Usage: node scripts/package-app-release.mjs <new-output-directory>");
|
||||
}
|
||||
|
||||
const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
||||
const outputRoot = resolve(process.cwd(), process.argv[2]);
|
||||
const relativeOutput = relative(repositoryRoot, outputRoot);
|
||||
if (relativeOutput === "" || (!relativeOutput.startsWith("..") && !isAbsolute(relativeOutput))) {
|
||||
fail("Output directory must be outside the repository");
|
||||
}
|
||||
if (existsSync(outputRoot)) fail("Output directory must not already exist");
|
||||
|
||||
const revision = run("git", ["rev-parse", "HEAD"], repositoryRoot).toLowerCase();
|
||||
if (!/^[0-9a-f]{40}$/.test(revision)) fail("Git did not return a full revision");
|
||||
if (run("git", ["status", "--porcelain"], repositoryRoot) !== "") {
|
||||
fail("Application releases require a clean Git checkout");
|
||||
}
|
||||
|
||||
for (const required of ["package.json", "package-lock.json", "dist/client", "dist/server"]) {
|
||||
if (!existsSync(join(repositoryRoot, required))) fail(`Missing built runtime path: ${required}`);
|
||||
}
|
||||
|
||||
const releaseId = revision.slice(0, 7);
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), `screener-app-${releaseId}-`));
|
||||
const runtimeRoot = join(temporaryRoot, "runtime");
|
||||
const verifyRoot = join(temporaryRoot, "verify");
|
||||
|
||||
try {
|
||||
mkdirSync(runtimeRoot, { recursive: true });
|
||||
for (const source of [
|
||||
join(repositoryRoot, "package.json"),
|
||||
join(repositoryRoot, "package-lock.json"),
|
||||
...regularFiles(join(repositoryRoot, "dist")),
|
||||
]) {
|
||||
copyRuntimeFile(repositoryRoot, runtimeRoot, source);
|
||||
}
|
||||
writeFileSync(join(runtimeRoot, "REVISION"), `${revision}\n`, "ascii");
|
||||
|
||||
const records = recordsFor(runtimeRoot);
|
||||
const manifestName = `screener-${releaseId}.manifest.tsv`;
|
||||
const artifactName = `screener-${releaseId}-runtime.tar.gz`;
|
||||
const descriptorName = `screener-${releaseId}.release.json`;
|
||||
mkdirSync(outputRoot, { recursive: false, mode: 0o700 });
|
||||
const manifestPath = join(outputRoot, manifestName);
|
||||
const artifactPath = join(outputRoot, artifactName);
|
||||
const descriptorPath = join(outputRoot, descriptorName);
|
||||
writeFileSync(
|
||||
manifestPath,
|
||||
records.map((record) => `${record.sha256}\t${record.size}\t${record.path}\n`).join(""),
|
||||
"ascii",
|
||||
);
|
||||
|
||||
run("tar", ["-czf", artifactPath, "-C", runtimeRoot, "."], repositoryRoot);
|
||||
validateArchiveEntries(run("tar", ["-tzf", artifactPath], repositoryRoot));
|
||||
mkdirSync(verifyRoot);
|
||||
run("tar", ["-xzf", artifactPath, "-C", verifyRoot], repositoryRoot);
|
||||
compareRecords(records, recordsFor(verifyRoot));
|
||||
|
||||
const html = readFileSync(join(runtimeRoot, "dist/client/index.html"), "utf8");
|
||||
const mainAssetMatch = html.match(/<script[^>]+src="\/(assets\/index-[A-Za-z0-9_-]+\.js)"/);
|
||||
if (!mainAssetMatch) fail("Built client HTML has no unique main asset");
|
||||
const mainAsset = mainAssetMatch[1];
|
||||
if (!existsSync(join(runtimeRoot, "dist/client", ...mainAsset.split("/")))) {
|
||||
fail("Built client main asset is missing");
|
||||
}
|
||||
|
||||
const descriptor = {
|
||||
schema: 1,
|
||||
revision,
|
||||
releaseId,
|
||||
artifact: artifactName,
|
||||
artifactSha256: sha256(artifactPath),
|
||||
manifest: manifestName,
|
||||
manifestSha256: sha256(manifestPath),
|
||||
fileCount: records.length,
|
||||
mainAsset,
|
||||
};
|
||||
writeFileSync(descriptorPath, `${JSON.stringify(descriptor, null, 2)}\n`, "ascii");
|
||||
process.stdout.write(`${JSON.stringify({ descriptor: descriptorPath, ...descriptor })}\n`);
|
||||
} finally {
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -25,6 +25,8 @@ docs/maintenance.md
|
||||
docs/需求理解.md
|
||||
docs/方案设计.md
|
||||
docs/deployment.md
|
||||
scripts/package-app-release.mjs
|
||||
deploy/release-app.sh
|
||||
docs/adr/0001-p2p-first-media-topology.md
|
||||
docs/research/agent-context-governance.md
|
||||
docs/research/webrtc-p2p-screen-sharing.md
|
||||
|
||||
Reference in new issue
Block a user