Skip to content

Commit 277ab6c

Browse files
authored
Handle Luau Execution with Luau! (#9)
No more Python scripts in the codebase. As a bonus, this also makes it so the instance name is retained when syncing. Previously, when inserting the asset the name in the DataModel would always be "Build."
1 parent dcbc3b4 commit 277ab6c

15 files changed

+293
-418
lines changed

.lune/example.luau

Lines changed: 0 additions & 25 deletions
This file was deleted.

examples/package/default.project.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "package",
2+
"name": "PackageInstanceName",
33
"tree": {
44
"$path": "src"
55
}

examples/package/deploy.luau

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
local rbxasset = require("@root/")
2+
3+
local process = require("@lune/process")
4+
5+
local apiKey = process.args[1]
6+
assert(apiKey, "argument #1 must be a valid Open Cloud API key")
7+
8+
assert(process.cwd:match("examples/package"), "you must be in the `examples/package` folder when running this script")
9+
10+
process.exec("rojo", { "build", "-o", "asset.rbxm" })
11+
12+
rbxasset.publishPackageAsync(process.cwd, "asset.rbxm", apiKey)

src/cloud/createLuauTask.luau

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
local serde = require("@lune/serde")
2+
3+
local fetch = require("@root/requests/fetch")
4+
local types = require("@root/types")
5+
6+
type AssetId = types.AssetId
7+
8+
type LuauTaskResponse = {
9+
binaryInput: string,
10+
binaryOutputUri: string,
11+
createTime: string,
12+
enableBinaryOutput: boolean,
13+
output: {
14+
results: { any },
15+
},
16+
path: string,
17+
script: string,
18+
state: string,
19+
updateTime: string,
20+
user: string,
21+
error: { [string]: any }?,
22+
}
23+
24+
local function createLuauTask(
25+
scriptContent: string,
26+
universeId: AssetId,
27+
placeId: AssetId,
28+
apiKey: string
29+
): LuauTaskResponse
30+
local res =
31+
fetch(`https://apis.roblox.com/cloud/v2/universes/{universeId}/places/{placeId}/luau-execution-session-tasks`, {
32+
method = "POST",
33+
headers = {
34+
["Content-Type"] = "application/json",
35+
["x-api-key"] = apiKey,
36+
},
37+
body = serde.encode("json", {
38+
["script"] = scriptContent,
39+
}),
40+
})
41+
42+
return serde.decode("json", res.body)
43+
end
44+
45+
return createLuauTask

src/cloud/executeLuauTask.luau

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
local serde = require("@lune/serde")
2+
3+
local logging = require("@root/logging")
4+
local types = require("@root/types")
5+
6+
local createLuauTask = require("./createLuauTask")
7+
local fetchLuauTaskLogs = require("./fetchLuauTaskLogs")
8+
local getVariablesFromTaskLogs = require("./getVariablesFromTaskLogs")
9+
local publishPlaceAsync = require("./publishPlaceAsync")
10+
local waitForTaskCompletion = require("./waitForTaskCompletion")
11+
12+
type AssetId = types.AssetId
13+
14+
local function executeLuauTask(
15+
placePath: string,
16+
scriptContent: string,
17+
universeId: AssetId,
18+
placeId: AssetId,
19+
apiKey: string
20+
): { [string]: string }?
21+
publishPlaceAsync(placePath, universeId, placeId, apiKey)
22+
23+
local boundary = ("-"):rep(80)
24+
logging.debug(`executing script content\n{boundary}\n{scriptContent}\n{boundary}`)
25+
26+
logging.info("starting Luau task...")
27+
local luauTask = createLuauTask(scriptContent, universeId, placeId, apiKey)
28+
logging.debug(`task created: {luauTask.path}`)
29+
30+
logging.info("waiting for task to complete...")
31+
luauTask = waitForTaskCompletion(luauTask.path, apiKey)
32+
logging.debug(`task is now in {luauTask.state} state`)
33+
34+
local logs = fetchLuauTaskLogs(luauTask.path, apiKey)
35+
if #logs == 0 then
36+
logging.info("the task did not produce any logs")
37+
return {}
38+
end
39+
40+
logging.info("task output:")
41+
for _, l in logs do
42+
logging.info(` {l}`)
43+
end
44+
45+
local variables = getVariablesFromTaskLogs(logs)
46+
47+
if variables then
48+
logging.debug("extracted variables from output:")
49+
for key, value in variables do
50+
logging.debug(` {key}="{value}"`)
51+
end
52+
end
53+
54+
if luauTask.state == "COMPLETE" then
55+
local output = luauTask.output
56+
57+
if output["results"] then
58+
logging.info("task output:")
59+
logging.info(serde.encode("json", output["results"], true))
60+
else
61+
logging.info("the task did not return any results")
62+
end
63+
else
64+
logging.err(`Task failed, error:\n{serde.encode("json", luauTask.error)}`)
65+
end
66+
67+
return variables
68+
end
69+
70+
return executeLuauTask

src/cloud/fetchLuauTaskLogs.luau

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
local serde = require("@lune/serde")
2+
3+
local fetch = require("@root/requests/fetch")
4+
5+
local function fetchLuauTaskLogs(taskPath: string, apiKey: string): { string }
6+
local res = fetch(`https://apis.roblox.com/cloud/v2/{taskPath}/logs`, {
7+
headers = {
8+
["x-api-key"] = apiKey,
9+
},
10+
})
11+
12+
local logs = serde.decode("json", res.body)
13+
14+
if #logs["luauExecutionSessionTaskLogs"] == 0 then
15+
return {} -- no logs found
16+
end
17+
return logs["luauExecutionSessionTaskLogs"][1]["messages"]
18+
end
19+
20+
return fetchLuauTaskLogs
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
local function getVariablesFromTaskLogs(taskLogs: { string }): { [string]: string }?
2+
-- If a task prints a string in the form `KEY=VALUE` we extract it as a
3+
-- variable with this function. This allows us to get some results from Luau
4+
-- tasks
5+
local variables: { [string]: string } = {}
6+
local foundVariables = false
7+
for _, line in taskLogs do
8+
local key, value = line:match("^(.+)=(.+)$")
9+
if key and value then
10+
variables[key] = value
11+
foundVariables = true
12+
end
13+
end
14+
15+
-- This is for ergonomics. If there's no variables then returning nil means
16+
-- the consumer doesn't have to iterate over the dictionary to know if
17+
-- there's any keys in it
18+
return if foundVariables then variables else nil
19+
end
20+
21+
return getVariablesFromTaskLogs

src/cloud/publishPlaceAsync.luau

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
local fs = require("@lune/fs")
2+
local serde = require("@lune/serde")
3+
4+
local fetch = require("@root/requests/fetch")
5+
local logging = require("@root/logging")
6+
local types = require("@root/types")
7+
8+
type AssetId = types.AssetId
9+
10+
local function publishPlaceAsync(
11+
placePath: string,
12+
universeId: AssetId,
13+
placeId: AssetId,
14+
apiKey: string,
15+
shouldPublish: boolean?
16+
)
17+
logging.debug("uploading place to Roblox")
18+
19+
local versionType = if shouldPublish then "Published" else "Saved"
20+
21+
local res =
22+
fetch(`https://apis.roblox.com/universes/v1/{universeId}/places/{placeId}/versions?versionType={versionType}`, {
23+
method = "POST",
24+
body = fs.readFile(placePath),
25+
headers = {
26+
["x-api-key"] = apiKey,
27+
["Content-Type"] = "application/xml",
28+
["Accept"] = "application/json",
29+
},
30+
})
31+
32+
if res.ok then
33+
local body = serde.decode("json", res.body)
34+
return body.versionNumber
35+
end
36+
end
37+
38+
return publishPlaceAsync
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
local serde = require("@lune/serde")
2+
local task = require("@lune/task")
3+
4+
local fetch = require("@root/requests/fetch")
5+
6+
local function waitForTaskCompletion(operationPath: string, apiKey: string)
7+
while true do
8+
local res = fetch(`https://apis.roblox.com/cloud/v2/{operationPath}`, {
9+
headers = {
10+
["x-api-key"] = apiKey,
11+
},
12+
})
13+
14+
local luauTask = serde.decode("json", res.body)
15+
16+
if luauTask.state ~= "PROCESSING" then
17+
return luauTask
18+
else
19+
task.wait(2)
20+
end
21+
end
22+
end
23+
24+
return waitForTaskCompletion

src/lib/runLuauTask.luau

Lines changed: 36 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,34 @@
11
local fs = require("@lune/fs")
2+
local roblox = require("@lune/roblox")
23
local serde = require("@lune/serde")
34

5+
local executeLuauTask = require("@root/cloud/executeLuauTask")
46
local logging = require("@root/logging")
57
local run = require("@root/lib/run")
68

79
local TEMP_DIR = "temp"
8-
local TASK_PATH = `{TEMP_DIR}/task.luau`
9-
local ROJO_PROJECT_PATH = `{TEMP_DIR}/default.project.json`
10-
local BUILD_PATH = `{TEMP_DIR}/build.rbxl`
11-
12-
local function extractVariablesFromOutput(output: string)
13-
local result: { [string]: string } = {}
14-
for key, value in output:gmatch("([%u_]+)=([%d%w]+)") do
15-
if key and value then
16-
value = value:gsub("(%s+)?.*(%s+)?", "")
17-
result[key] = value
18-
end
19-
end
2010

21-
return result
11+
local function buildLuauExecutionRobloxPlace(outputPath: string): string
12+
local rojoProjectPath = `{outputPath}/default.project.json`
13+
local placePath = `{outputPath}/build.rbxl`
14+
15+
logging.info("creating Rojo project")
16+
local rojoProject = {
17+
name = "LuauExecutionPlace",
18+
tree = {
19+
["$className"] = "DataModel",
20+
},
21+
}
22+
23+
fs.writeFile(rojoProjectPath, serde.encode("json", rojoProject))
24+
25+
run("rojo", { "build", rojoProjectPath, "-o", placePath })
26+
27+
return placePath
2228
end
2329

2430
local function runLuauTask(
25-
taskBody: string,
31+
scriptContent: string,
2632
globals: {
2733
[string]: string | number | boolean,
2834
},
@@ -35,50 +41,34 @@ local function runLuauTask(
3541
)
3642
run("rm", { "-rf", TEMP_DIR })
3743

38-
if not fs.isDir("temp") then
39-
fs.writeDir("temp")
44+
if not fs.isDir(TEMP_DIR) then
45+
fs.writeDir(TEMP_DIR)
4046
end
41-
fs.writeFile(TASK_PATH, taskBody)
4247

43-
logging.info("creating Rojo project")
44-
local rojoProject = {
45-
name = "Build",
46-
tree = {
47-
["$className"] = "DataModel",
48-
ReplicatedStorage = {
49-
Build = if runnerContext.modelPath
50-
then {
51-
["$path"] = runnerContext.modelPath,
52-
}
53-
else nil,
54-
},
55-
},
56-
}
57-
fs.writeFile(ROJO_PROJECT_PATH, serde.encode("json", rojoProject))
48+
local placePath = buildLuauExecutionRobloxPlace(TEMP_DIR)
5849

59-
run("rojo", { "build", ROJO_PROJECT_PATH, "-o", BUILD_PATH })
50+
if runnerContext.modelPath then
51+
local game = roblox.deserializePlace(fs.readFile(placePath))
52+
local root = roblox.deserializeModel(fs.readFile(runnerContext.modelPath))
53+
for _, child in root do
54+
child.Parent = game:GetService("ReplicatedStorage")
55+
logging.debug(`parented {child} to ReplicatedStorage`)
56+
end
57+
fs.writeFile(placePath, roblox.serializePlace(game))
58+
end
6059

6160
logging.info("substituting globals", globals)
6261

6362
for global, value in globals do
64-
run("sed", { "-i", "-e", `'s/= _G.{global}/= "{value}"/g'`, TASK_PATH })
63+
scriptContent = scriptContent:gsub(`= _G.{global}`, `= "{value}"`)
6564
end
6665

67-
local output = run("python3", {
68-
"src/python/upload_and_run_task.py",
69-
BUILD_PATH,
70-
TASK_PATH,
71-
}, {
72-
env = {
73-
ROBLOX_CI_UNIVERSE_ID = runnerContext.universeId,
74-
ROBLOX_CI_PLACE_ID = runnerContext.placeId,
75-
ROBLOX_API_KEY = runnerContext.apiKey,
76-
},
77-
})
66+
local variables =
67+
executeLuauTask(placePath, scriptContent, runnerContext.universeId, runnerContext.placeId, runnerContext.apiKey)
7868

7969
run("rm", { "-rf", TEMP_DIR })
8070

81-
return extractVariablesFromOutput(output)
71+
return variables
8272
end
8373

8474
return runLuauTask

0 commit comments

Comments
 (0)