Skip to content

Instantly share code, notes, and snippets.

@boatbomber
Last active October 30, 2024 21:58
Show Gist options
  • Save boatbomber/85f1412d56086a1638055db6e1de550b to your computer and use it in GitHub Desktop.
Save boatbomber/85f1412d56086a1638055db6e1de550b to your computer and use it in GitHub Desktop.
Release workflow using Lune
--[[
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