Last active
October 30, 2024 21:58
-
-
Save boatbomber/85f1412d56086a1638055db6e1de550b to your computer and use it in GitHub Desktop.
Release workflow using Lune
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--[[ | |
release.luau - A Lune script for publishing Roblox games | |
MPL 2.0 License | |
(c) 2024, Zack Ovits | |
usage: lune run release | |
--]] | |
-- Lune libraries | |
local stdio = require("@lune/stdio") | |
local fs = require("@lune/fs") | |
local task = require("@lune/task") | |
local net = require("@lune/net") | |
local serde = require("@lune/serde") | |
local process = require("@lune/process") | |
-- Constants | |
local CLEAR_LINE = "\x1b[2K\r" | |
local BAR_CHARS = { " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉" } | |
local SPINNER_FRAMES = { "◜", "◝", "◞", "◟" } | |
-- Output utilities | |
local function richPrint(color, style, ...) | |
stdio.write( | |
stdio.color(color) | |
.. stdio.style(style) | |
.. (...) | |
.. "\n" | |
.. stdio.color("reset") | |
.. stdio.style("reset") | |
) | |
end | |
function createBar(percent: number?, text: string?) | |
local bar = { | |
percent = math.clamp(percent or 0, 0, 1), | |
length = 10, | |
text = tostring(text or ""), | |
} | |
function bar.setLength(newLength: number) | |
bar.length = math.clamp(newLength, 2, 100) | |
end | |
function bar.setPercent(newPercent: number) | |
bar.percent = math.clamp(newPercent, 0, 1) | |
end | |
function bar.setText(newText: string) | |
bar.text = tostring(newText) | |
end | |
function bar.render() | |
local wholeWidth = math.floor(bar.percent * bar.length) | |
local remainderWidth = (bar.percent * bar.length) % 1 | |
local partWidth = math.floor(remainderWidth * 8) | |
local partChar = if wholeWidth >= bar.length then "" else BAR_CHARS[partWidth + 1] | |
local barText = "[" | |
.. string.rep("█", wholeWidth) | |
.. partChar | |
.. string.rep(" ", bar.length - wholeWidth - 1) | |
.. "]" | |
stdio.write( | |
CLEAR_LINE | |
.. stdio.style("dim") | |
.. stdio.color("green") | |
.. barText | |
.. stdio.style("bold") | |
.. string.format(" %.1f%% ", bar.percent * 100) | |
.. stdio.style("reset") | |
.. stdio.color("reset") | |
.. bar.text or "" | |
) | |
end | |
function bar.clear() | |
stdio.write(CLEAR_LINE) | |
bar.percent = 0 | |
bar.text = "" | |
end | |
return bar | |
end | |
function createSpinner() | |
local spinner = { | |
animating = false, | |
animateThread = nil, | |
text = "", | |
frame = 1, | |
} | |
function spinner.setText(newText: string) | |
spinner.text = tostring(newText) | |
end | |
function spinner.start() | |
if spinner.animateThread then | |
return | |
end | |
spinner.animating = true | |
spinner.frame = 1 | |
spinner.animateThread = task.spawn(function() | |
while spinner.animating do | |
stdio.write( | |
CLEAR_LINE | |
.. stdio.style("bold") | |
.. stdio.color("green") | |
.. SPINNER_FRAMES[spinner.frame] | |
.. stdio.style("reset") | |
.. stdio.color("reset") | |
.. " " | |
.. spinner.text | |
) | |
spinner.frame += 1 | |
if spinner.frame > #SPINNER_FRAMES then | |
spinner.frame = 1 | |
end | |
task.wait(1 / 20) | |
end | |
end) | |
end | |
function spinner.stop() | |
spinner.animating = false | |
task.cancel(spinner.animateThread) | |
spinner.animateThread = nil | |
spinner.text = "" | |
spinner.frame = 1 | |
stdio.write(CLEAR_LINE) | |
end | |
return spinner | |
end | |
-- File utilities | |
local function ensureFileExists(path: string, failMsg: string?) | |
if not fs.isFile(path) then | |
richPrint("red", "bold", failMsg or "❌ File does not exist: " .. path) | |
process.exit(1) | |
return | |
end | |
end | |
local function countFiles(entryName) | |
local files = 0 | |
if fs.isFile(entryName) then | |
files += 1 | |
elseif fs.isDir(entryName) then | |
for _, subEntryName in fs.readDir(entryName) do | |
files += countFiles(entryName .. "/" .. subEntryName) | |
end | |
end | |
return files | |
end | |
local function removeFileMatches(entryName, pattern) | |
local removals = 0 | |
if fs.isFile(entryName) and string.find(entryName, pattern) then | |
removals += 1 | |
fs.removeFile(entryName) | |
elseif fs.isDir(entryName) then | |
for _, subEntryName in fs.readDir(entryName) do | |
removals += removeFileMatches(entryName .. "/" .. subEntryName, pattern) | |
end | |
end | |
return removals | |
end | |
local function readPathsFromProject(projectPath: string) | |
-- This assumes that the project file exists and is JSON | |
local projectFile = fs.readFile(projectPath) | |
local projectJson = serde.decode("json", projectFile) | |
local paths = {} | |
local function walkForPaths(project) | |
if project["$path"] then | |
table.insert(paths, project["$path"]) | |
end | |
for _, child in project do | |
if type(child) ~= "table" then | |
continue | |
end | |
walkForPaths(child) | |
end | |
end | |
walkForPaths(projectJson) | |
return paths | |
end | |
local function createProjectCopyWithPrefixedPaths(projectPath: string, prefix: string) | |
-- This assumes that the project file exists and is JSON | |
local projectFile = fs.readFile(projectPath) | |
local projectJson = serde.decode("json", projectFile) | |
local function walkForPaths(obj) | |
if obj["$path"] then | |
-- We can modify in place since the decode is a deep copy, not altering the original file | |
obj["$path"] = prefix .. obj["$path"] | |
end | |
for _, child in obj do | |
if type(child) ~= "table" then | |
continue | |
end | |
walkForPaths(child) | |
end | |
end | |
walkForPaths(projectJson) | |
return projectJson | |
end | |
-- Validation/Utility functions | |
local function validateConfig(releaseConfig: { [any]: any? }) | |
if | |
type(releaseConfig.auth) ~= "table" | |
or type(releaseConfig.auth.publishKey) ~= "string" | |
then | |
richPrint("red", "bold", "Invalid releaseconfig.toml file.") | |
richPrint( | |
"red", | |
"bold", | |
'Please ensure it contains a valid auth key like so:\n[auth]\npublishKey = "YOUR_CLOUD_KEY"' | |
) | |
process.exit(1) | |
return | |
end | |
if | |
type(releaseConfig.build) ~= "table" | |
or type(releaseConfig.build.project) ~= "string" | |
or type(releaseConfig.build.output) ~= "string" | |
then | |
richPrint("red", "bold", "Invalid releaseconfig.toml file.") | |
richPrint( | |
"red", | |
"bold", | |
'Please ensure it contains a valid build table like so:\n[build]\nproject = "default.project.json"\noutput = "build.rbxl"' | |
) | |
process.exit(1) | |
return | |
end | |
if type(releaseConfig.environments) ~= "table" or next(releaseConfig.environments) == nil then | |
richPrint("red", "bold", "Invalid releaseconfig.toml file.") | |
richPrint( | |
"red", | |
"bold", | |
'Please ensure it contains a valid environments table like so:\n[environments]\nEnvName = "universeId/placeId"' | |
) | |
process.exit(1) | |
return | |
end | |
for envName, envId in releaseConfig.environments do | |
if | |
type(envName) ~= "string" | |
or type(envId) ~= "string" | |
or string.find(envId, "/") == nil | |
or string.match(envId, "[^%d/]") ~= nil | |
then | |
richPrint("red", "bold", "Invalid releaseconfig.toml file.") | |
richPrint( | |
"red", | |
"bold", | |
"Your environments contain an invalid id, '" | |
.. tostring(envName) | |
.. " = " | |
.. tostring(envId) | |
.. "'." | |
.. '\nPlease ensure it contains a valid environments table like so:\n[environments]\nenvName = "universeId/placeId"' | |
) | |
process.exit(1) | |
return | |
end | |
end | |
end | |
local function validateTestsConfig(releaseConfig: { [any]: any? }) | |
if | |
type(releaseConfig.tests) ~= "table" | |
or type(releaseConfig.tests.project) ~= "string" | |
or type(releaseConfig.tests.output) ~= "string" | |
or type(releaseConfig.tests.runner) ~= "string" | |
then | |
richPrint("red", "bold", "The releaseconfig.toml file does not have a valid test set up.") | |
richPrint( | |
"red", | |
"bold", | |
'Please ensure it contains a valid tests table like so:\n[tests]\nproject = "tests.project.json"\noutput = "tests.rbxl"\nrunner = "tests/runner.lua"' | |
) | |
process.exit(1) | |
return | |
end | |
end | |
local function validateGitStatus(gitStatus: { string }, projectPaths: { string }, extraPaths: { string }) | |
local relevantPaths = table.create(#projectPaths + #extraPaths) | |
table.move(projectPaths, 1, #projectPaths, 1, relevantPaths) | |
table.move(extraPaths, 1, #extraPaths, #projectPaths + 1, relevantPaths) | |
local shouldAbort = false | |
for _, status in gitStatus do | |
if string.find(status, "%S") == nil then | |
-- Blank status line | |
continue | |
end | |
local state, file = string.match(status, "(%S+)%s+(.+)$") | |
if state == nil or file == nil then | |
richPrint("red", "bold", "Failed to parse git status line: " .. status) | |
process.exit(1) | |
return | |
end | |
local stateText = if string.find(state, "M") | |
then " has uncommitted changes" | |
elseif string.find(state, "D") then " has an uncommitted removal" | |
elseif string.find(state, "R") then " has an uncommitted rename" | |
else " has not been committed" | |
if | |
string.find(file, "%.luau?$") | |
or string.find(file, "%.rbxmx?$") | |
or string.find(file, "%.json5?$") | |
or string.find(file, "%.txt$") | |
or string.find(file, "%.toml$") | |
then | |
for _, path in relevantPaths do | |
if string.find(file, "^" .. path) then | |
richPrint("red", "bold", file .. stateText) | |
shouldAbort = true | |
break | |
end | |
end | |
end | |
end | |
if shouldAbort then | |
richPrint("red", "bold", "Please commit your changes before continuing.") | |
process.exit(1) | |
return | |
end | |
end | |
local function filterThirdPartyPaths(paths: { string }): { string } | |
local filtered = table.create(#paths) -- Likely allocating more than we need, but it's negligible | |
for _, path in paths do | |
if string.find(path, "[Pp]ackages") or string.find(path, "[Vv]endor") then | |
-- Skip packages and vendor directories | |
continue | |
end | |
table.insert(filtered, path) | |
end | |
return filtered | |
end | |
-- Subcommand functions | |
local subcommands = { | |
["generate-config"] = function() | |
richPrint("green", "bold", "⚙️ Generating a template releaseconfig.toml file!\n") | |
fs.writeFile( | |
"releaseconfig.toml", | |
[[[build] | |
project = "default.project.json" | |
output = "build.rbxl" | |
[tests] | |
project = "tests.project.json" | |
output = "tests.rbxl" | |
runner = "tests/runner.lua" | |
[environments] | |
Testing = "universeId/placeId" | |
Staging = "universeId/placeId" | |
Production = "universeId/placeId" | |
[auth] | |
publishKey = "YOUR_CLOUD_KEY" # Needs Place Publishing>Write scope and Experiences>Write Universes scope | |
]] | |
) | |
end, | |
} | |
local function main() | |
-- Run subcommand if present | |
local subcommandHandler = subcommands[process.args[1]] | |
if subcommandHandler then | |
local success, msg = pcall(subcommandHandler) | |
if not success then | |
richPrint("red", "bold", "🚨 Error: " .. msg) | |
process.exit(1) | |
else | |
richPrint("blue", "bold", "\n🎉 We're all done!") | |
process.exit(0) | |
end | |
return | |
end | |
-- Not running a subcommand, so we're doing a publish process | |
richPrint("green", "bold", "🎬 Let's publish a new release!\n") | |
-- Create an output spinner to show during tasks | |
local spinner = createSpinner() | |
-- Handle config | |
richPrint("blue", "bold", "⚙️ Reading releaseconfig file...") | |
ensureFileExists( | |
"releaseconfig.toml", | |
"❌ No releaseconfig.toml file found.\nRun `lune release generate-config` to generate a template file." | |
) | |
spinner.setText("Validating releaseconfig.toml...") | |
spinner.start() | |
local releaseConfig = serde.decode("toml", fs.readFile("releaseconfig.toml")) | |
validateConfig(releaseConfig) | |
spinner.stop() | |
richPrint("blue", "dim", "Validated release configuration!") | |
-- Gather project info | |
richPrint("blue", "bold", "🛠️ Reading project file...") | |
ensureFileExists( | |
releaseConfig.build.project, | |
"❌ Could not find project file '" | |
.. releaseConfig.build.project | |
.. "'. Please ensure your releaseconfig.toml file contains a valid project file path." | |
) | |
spinner.setText("Parsing " .. releaseConfig.build.project .. "...") | |
spinner.start() | |
local pathsUsedByProject = readPathsFromProject(releaseConfig.build.project) | |
spinner.stop() | |
richPrint("blue", "dim", "Found " .. #pathsUsedByProject .. " paths used in project!") | |
-- Upload tarmac assets if necessary | |
if fs.isFile("tarmac.toml") then | |
richPrint("blue", "bold", "📤 Uploading assets...") | |
spinner.setText("Running tarmac sync...") | |
spinner.start() | |
local assetsResult = process.spawn("tarmac", { "sync", "--target", "roblox", "--retry", "3" }) | |
spinner.stop() | |
if assetsResult.ok == false then | |
richPrint("red", "bold", "Asset upload failure:") | |
richPrint("red", assetsResult.stderr) | |
process.exit(1) | |
return | |
end | |
local _, assetsUploaded = string.gsub(assetsResult.stderr, "Uploaded .- to ID %d+", "") | |
if assetsUploaded > 0 then | |
richPrint("blue", "dim", "Uploaded " .. assetsUploaded .. " assets!") | |
elseif string.find(assetsResult.stderr, "all inputs are unchanged%.%s*$") then | |
richPrint("blue", "dim", "No assets need to upload!") | |
else | |
richPrint("blue", "dim", "Tarmac result: " .. assetsResult.stderr) | |
end | |
end | |
-- Install wally packages if necessary | |
if fs.isFile("wally.toml") then | |
richPrint("blue", "bold", "📥 Installing dependencies...") | |
spinner.setText("Running wally install...") | |
spinner.start() | |
local dependenciesResult = process.spawn("wally", { "install" }) | |
spinner.stop() | |
if dependenciesResult.ok == false then | |
richPrint("red", "bold", "Dependency install failure:") | |
richPrint("red", dependenciesResult.stderr) | |
process.exit(1) | |
return | |
end | |
-- wally uses stderr instead of stdout for some reason | |
local packagesInstalled = string.match(dependenciesResult.stderr, "Downloaded (%d+) packages") or 0 | |
richPrint("blue", "dim", "Installed " .. packagesInstalled .. " packages!") | |
end | |
-- Gather version control info | |
richPrint("blue", "bold", "💾 Reading version control info...") | |
local latestCommit = | |
string.gsub(process.spawn("git", { "log", "--pretty=reference", "-n", "1" }).stdout, "\n$", "") | |
local gitBranch = string.gsub(process.spawn("git", { "branch", "--show-current" }).stdout, "\n$", "") | |
local gitStatus = | |
string.split(string.gsub(process.spawn("git", { "status", "--porcelain" }).stdout, "\n$", ""), "\n") | |
-- Ensure no uncommitted changes to files we use | |
validateGitStatus(gitStatus, pathsUsedByProject, { | |
"releaseconfig.toml", | |
releaseConfig.build.project, | |
}) | |
richPrint("blue", "dim", "All necessary files are committed and ready!") | |
richPrint("green", "bold", "\n✅ Looks like we're good to get started!") | |
stdio.write("\n") | |
local shouldLint = stdio.prompt("confirm", "Would you like to run the linter first?") | |
if shouldLint then | |
local lintTargets = filterThirdPartyPaths(pathsUsedByProject) | |
richPrint("blue", "bold", "🔍 Analyzing " .. table.concat(lintTargets, ", ") .. "...") | |
-- Run selene with direct stdio | |
local lintResult = process.spawn("selene", { | |
"--config", | |
"selene.toml", | |
table.unpack(lintTargets), | |
}, { | |
stdio = "inherit", | |
}) | |
if lintResult.ok == false then | |
process.exit(1) | |
return | |
end | |
stdio.write("\n") | |
end | |
stdio.write("\n") | |
local shouldTest = stdio.prompt("confirm", "Would you like to run the test suite before we continue?") | |
if shouldTest then | |
validateTestsConfig(releaseConfig) | |
richPrint("blue", "bold", "🏗️ Building test file...") | |
local testBuildResult = process.spawn("rojo", { | |
"build", | |
releaseConfig.tests.project, | |
"--output", | |
releaseConfig.tests.output, | |
}) | |
if testBuildResult.ok == false then | |
richPrint("red", "bold", "Test build failure:") | |
richPrint("red", testBuildResult.stderr) | |
process.exit(1) | |
return | |
end | |
richPrint("blue", "bold", "🔬 Running tests...") | |
local testResult = process.spawn("run-in-roblox", { | |
"--place", | |
releaseConfig.tests.output, | |
"--script", | |
releaseConfig.tests.runner, | |
}, { stdio = "inherit" }) | |
if testResult.ok == false then | |
process.exit(1) | |
return | |
end | |
-- Cleanup test file | |
fs.removeFile(releaseConfig.tests.output) | |
end | |
-- We're about to decide how to build (processed or raw), then we'll read it to this variable for later | |
local buildFile = nil | |
stdio.write("\n") | |
local shouldProcess = stdio.prompt("confirm", "Do you want to process & minify the source code?") | |
if shouldProcess then | |
-- We are going to process the source into new files, | |
-- so we need to create a modified project file to point at them | |
local topLevelPaths = {} | |
for _, path in pathsUsedByProject do | |
local topPath = string.match(path, "^[^/\\]+") | |
topLevelPaths[topPath] = true | |
end | |
local totalFiles, checkedFiles = 0, 0 | |
for path in topLevelPaths do | |
totalFiles += countFiles(path) | |
end | |
-- Create a new project file that points at the processed files | |
richPrint("blue", "bold", "🏗️ Generating processed project file...") | |
local processedProject = createProjectCopyWithPrefixedPaths(releaseConfig.build.project, "processed-") | |
fs.writeFile("processed.project.json", serde.encode("json", processedProject)) | |
-- Create an output progress bar since this next step can take a while | |
local processProgressBar = createBar() | |
richPrint("blue", "bold", "🔨 Processing source code...") | |
local function processFiles(entryName) | |
local processed, skipped = 0, 0 | |
if fs.isFile(entryName) then | |
checkedFiles += 1 | |
processProgressBar.setPercent(checkedFiles / totalFiles) | |
processProgressBar.setText( | |
"File " .. checkedFiles .. ": " .. (string.match(entryName, "[^/\\]+$") or entryName) .. "..." | |
) | |
processProgressBar.render() | |
if string.find(entryName, "%.luau?$") then | |
local subEntryProcessResult = process.spawn( | |
"darklua", | |
{ "process", entryName, "processed-" .. entryName, "--format", "retain_lines" } | |
) | |
if subEntryProcessResult.ok == true then | |
processed += 1 | |
else | |
-- Couldn't process this file, so we'll just copy it as-is | |
skipped += 1 | |
fs.writeFile("processed-" .. entryName, fs.readFile(entryName)) | |
end | |
else | |
-- This isn't a lua file, so we'll just copy it as-is | |
skipped += 1 | |
fs.writeFile("processed-" .. entryName, fs.readFile(entryName)) | |
end | |
elseif fs.isDir(entryName) then | |
fs.writeDir("processed-" .. entryName) | |
for _, subEntryName in fs.readDir(entryName) do | |
local subProcessed, subSkipped = processFiles(entryName .. "/" .. subEntryName) | |
processed += subProcessed | |
skipped += subSkipped | |
end | |
end | |
return processed, skipped | |
end | |
local totalProcessed, totalSkipped = 0, 0 | |
for path in topLevelPaths do | |
local pathProcessed, pathSkipped = processFiles(path) | |
totalProcessed += pathProcessed | |
totalSkipped += pathSkipped | |
end | |
processProgressBar.clear() | |
richPrint( | |
"blue", | |
"dim", | |
"Processed " .. totalProcessed .. " files, and left " .. totalSkipped .. " files as-is!" | |
) | |
-- Let's chop out some files we won't need in production | |
richPrint("blue", "bold", "🧾Removing story files...") | |
local storyRemovals = 0 | |
for path in topLevelPaths do | |
storyRemovals += removeFileMatches("processed-" .. path, "[%._]story%.luau?$") | |
end | |
richPrint("blue", "dim", "Removed " .. storyRemovals .. " story files!") | |
richPrint("blue", "bold", "🧾Removing spec files...") | |
local specRemovals = 0 | |
for path in topLevelPaths do | |
specRemovals += removeFileMatches("processed-" .. path, "[%._]spec%.luau?$") | |
end | |
richPrint("blue", "dim", "Removed " .. specRemovals .. " spec files!") | |
-- At this point, we have processed copies of the needed source files with a project JSON that points at them | |
-- We can now build using them | |
richPrint("blue", "bold", "🏗️ Building place file...") | |
local buildResult = process.spawn("rojo", { | |
"build", | |
"processed.project.json", | |
"--output", | |
releaseConfig.build.output, | |
}) | |
if buildResult.ok == false then | |
richPrint("red", "bold", "Build failure:") | |
richPrint("red", buildResult.stderr) | |
process.exit(1) | |
return | |
end | |
buildFile = fs.readFile(releaseConfig.build.output) | |
richPrint("blue", "dim", "Built project to `" .. releaseConfig.build.output .. "`!") | |
-- Now that we built that, cleanup all the processed files | |
fs.removeFile("processed.project.json") | |
for path in topLevelPaths do | |
fs.removeDir("processed-" .. path) | |
end | |
else | |
-- No changes to the source, so we can just use the existing project file and build | |
richPrint("blue", "bold", "🏗️ Building place file...") | |
local buildResult = process.spawn("rojo", { | |
"build", | |
releaseConfig.build.project, | |
"--output", | |
releaseConfig.build.output, | |
}) | |
if buildResult.ok == false then | |
richPrint("red", "bold", "Build failure:") | |
richPrint("red", buildResult.stderr) | |
process.exit(1) | |
return | |
end | |
buildFile = fs.readFile(releaseConfig.build.output) | |
richPrint("blue", "dim", "Built project to `" .. releaseConfig.build.output .. "`!") | |
end | |
stdio.write("\n") | |
richPrint("green", "bold", "🏁 We're nearly ready to publish!") | |
-- Now we need to get the universe ID and place ID | |
local enviromentOptions = {} | |
for envId in releaseConfig.environments do | |
table.insert(enviromentOptions, envId) | |
end | |
table.sort(enviromentOptions) -- Sort alphabetically for consistency | |
stdio.write("\n") | |
local envChoice = stdio.prompt("select", "Which environment would you like to publish to?", enviromentOptions) | |
local envTarget = enviromentOptions[envChoice] | |
local envConfig = releaseConfig.environments[envTarget] | |
stdio.write("\n") | |
local shouldShutdown = stdio.prompt("confirm", "Would you like to soft-shutdown the servers after publishing?") | |
-- We have everything we need, but let's confirm before we publish | |
stdio.write("\n") | |
richPrint("red", "dim", "🛂 Let's double check before we publish!") | |
richPrint( | |
"white", | |
"reset", | |
string.format("Source Commit: %s@%s", gitBranch, latestCommit) | |
.. string.format("\nEnvironment: %s", envTarget) | |
.. string.format("\nBuild File: " .. releaseConfig.build.output .. " (%d KB)", #buildFile / 1024) | |
.. string.format("\nSoft Shutdown: %s", if shouldShutdown then "Yes" else "No") | |
) | |
stdio.write("\n") | |
local doubleChecked = stdio.prompt("confirm", "Is everything correct?") | |
stdio.write("\n") | |
if not doubleChecked then | |
richPrint("red", "bold", "🛑 Publishing aborted, let's try again later.") | |
process.exit(1) | |
return | |
end | |
-- We are good to go! | |
richPrint("blue", "bold", "🚀 Publishing to " .. envTarget .. "...") | |
spinner.setText("Deploying via Open Cloud API...") | |
spinner.start() | |
local universeId, placeId = string.match(envConfig, "(%d+)/(%d+)") | |
if universeId == nil or placeId == nil then | |
richPrint("red", "bold", "Could not parse IDs from environment configuration '" .. envConfig .. "'!") | |
process.exit(1) | |
return | |
end | |
local publishSuccess, publishResponse = pcall(net.request, { | |
url = string.format( | |
"https://apis.roblox.com/universes/v1/%s/places/%s/versions?versionType=Published", | |
universeId, | |
placeId | |
), | |
method = "POST", | |
headers = { | |
["x-api-key"] = releaseConfig.auth.publishKey, | |
["Content-Type"] = "application/octet-stream", | |
}, | |
body = buildFile, | |
}) | |
spinner.stop() | |
if publishSuccess == false then | |
richPrint("red", "bold", "Publish failure:") | |
richPrint("red", "reset", publishResponse) | |
process.exit(1) | |
return | |
elseif publishResponse.ok == false then | |
richPrint( | |
"red", | |
"bold", | |
"Publish failure: " .. publishResponse.statusCode .. " " .. publishResponse.statusMessage | |
) | |
richPrint("red", "reset", publishResponse.body) | |
process.exit(1) | |
return | |
end | |
local body = net.jsonDecode(publishResponse.body) | |
richPrint("blue", "dim", "Place has been published to place version " .. body.versionNumber .. "!") | |
if shouldShutdown then | |
spinner.setText("Sending server shutdown message...") | |
spinner.start() | |
local messageSuccess, messageResponse = pcall(net.request, { | |
url = string.format( | |
"https://apis.roblox.com/cloud/v2/universes/%s:restartServers", | |
universeId | |
), | |
method = "POST", | |
headers = { | |
["x-api-key"] = releaseConfig.auth.publishKey, | |
["Content-Type"] = "application/json", | |
}, | |
body = "{}", | |
}) | |
spinner.stop() | |
if messageSuccess == false then | |
richPrint("red", "bold", "Server restart failure:") | |
richPrint("red", "reset", messageResponse) | |
process.exit(1) | |
return | |
elseif messageResponse.ok == false then | |
richPrint( | |
"red", | |
"bold", | |
"Server restart failure: " .. messageResponse.statusCode .. " " .. messageResponse.statusMessage | |
) | |
richPrint("red", "reset", messageResponse.body) | |
process.exit(1) | |
return | |
end | |
richPrint("blue", "dim", "Shutdown trigger has been sent!") | |
end | |
end | |
-- Run | |
local success, msg = pcall(main) | |
if not success then | |
richPrint("red", "bold", "🚨 Error: " .. tostring(msg)) | |
process.exit(1) | |
return | |
else | |
richPrint("blue", "bold", "\n🎉 We're all done!") | |
process.exit(0) | |
return | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment