Skip to content

Commit

Permalink
Plugin publishing workflow (#285)
Browse files Browse the repository at this point in the history
This PR introduces some automation to publish Flipbook builds to the
[dev
plugin](https://create.roblox.com/store/asset/88523969718241/flipbook-dev)
one merge to `main`. Now all our recent changes will be testable in
Studio without any extra work on our part

Later on I plan to introduce automation to publish to the production
plugin, but for now I just want to setup nightlies

Resolves #284
  • Loading branch information
vocksel authored Nov 21, 2024
1 parent 2d442ac commit 4cd6fd6
Show file tree
Hide file tree
Showing 10 changed files with 484 additions and 18 deletions.
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,20 @@ jobs:
files: ${{ env.MODEL_FILE }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

publish-plugin-nightly:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
timeout-minutes: 10
steps:
- uses: actions/checkout@v3

- uses: Roblox/setup-foreman@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Install packages
run: lune run wally-install

- name: Publish to Creator Store
run: lune run publish-plugin -- --apiKey ${{ secrets.ROBLOX_API_KEY }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@ DevPackages
Packages
wally.lock

# Python
__pycache__

# Other
/*.log
12 changes: 0 additions & 12 deletions .lune/build.luau
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,10 @@ assert(target == "dev" or target == "prod", `bad value for target (must be one o
local output = if args.output then args.output else `{getPluginsPath(process.os)}/{constants.PLUGIN_FILENAME}`
assert(typeof(output) == "string", `bad value for output (string expected, got {typeof(output)})`)

local PRUNED_FILES = {
"*.spec.luau",
"*.story.luau",
"*.storybook.luau",
}

local function build()
clean()
compile(target)

if target == "prod" then
for _, pattern in PRUNED_FILES do
run("find", { constants.BUILD_PATH, "-type", "f", "-name", pattern, "-delete" })
end
end

run("rojo", { "build", "-o", output })
end

Expand Down
12 changes: 12 additions & 0 deletions .lune/lib/compile.luau
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ local run = require("./run")

type Target = "prod" | "dev"

local PRUNED_FILES = {
"*.spec.luau",
"*.story.luau",
"*.storybook.luau",
}

local function compile(target: Target)
fs.writeDir(constants.BUILD_PATH)

Expand All @@ -27,6 +33,12 @@ local function compile(target: Target)
`{constants.BUILD_PATH}/Example`,
})
end

if target == "prod" then
for _, pattern in PRUNED_FILES do
run("find", { constants.BUILD_PATH, "-type", "f", "-name", pattern, "-delete" })
end
end
end

return compile
13 changes: 7 additions & 6 deletions .lune/lib/run.luau
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ local function run(program: string, params: { string }, options: Options?)
env = if options then options.env else nil,
})

if result.code > 0 then
process.exit(result.code)
local out
if result.ok then
out = result.stdout
else
out = result.stderr
end

local output = if result.ok then result.stdout else result.stderr
out = out:gsub("\n$", "")

output = output:gsub("\n$", "")

return output
return out, result.ok
end

return run
272 changes: 272 additions & 0 deletions .lune/open-cloud/luau_execution_task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
# upstream: https://raw.githubusercontent.com/Roblox/place-ci-cd-demo/refs/heads/production/scripts/python/luau_execution_task.py

import argparse
import logging
import urllib.request
import urllib.error
import base64
import sys
import json
import time
import hashlib
import os


def parseArgs():
parser = argparse.ArgumentParser()

parser.add_argument(
"-k",
"--api-key",
help="Path to a file containing your OpenCloud API key. You can also use the environment variable RBLX_OC_API_KEY to specify the API key. This option takes precedence over the environment variable.",
metavar="<path to API key file>",
)
parser.add_argument(
"-u",
"--universe",
"-e",
"--experience",
required=True,
help="ID of the experience (a.k.a. universe) containing the place you want to execute the task against.",
metavar="<universe id>",
type=int,
)
parser.add_argument(
"-p",
"--place",
required=True,
help="ID of the place you want to execute the task against.",
metavar="<place id>",
type=int,
)
parser.add_argument(
"-v",
"--place-version",
help="Version of the place you want to execute the task against. If not given, the latest version of the place will be used.",
metavar="<place version>",
type=int,
)
parser.add_argument(
"-f",
"--script-file",
required=True,
help="Path to a file containing your Luau script.",
metavar="<path to Luau script file>",
)
parser.add_argument(
"-c",
"--continuous",
help="If specified, this script will run in a loop and automatically create a new task after the previous task has finished, but only if the script file is updated. If the script file has not been updated, this script will wait for it to be updated before submitting a new task.",
action="store_true",
)
parser.add_argument(
"-o",
"--output",
help="Path to a file to write the task's output to. If not given, output is written to stdout.",
metavar="<path to output file>",
)
parser.add_argument(
"-l",
"--log-output",
help="Path to a file to write the task's logs to. If not given, logs are written to stderr.",
metavar="<path to log output file>",
)

return parser.parse_args()


def makeRequest(url, headers, body=None):
data = None
if body is not None:
data = body.encode("utf8")
request = urllib.request.Request(
url, data=data, headers=headers, method="GET" if body is None else "POST"
)
max_attempts = 3
for i in range(max_attempts):
try:
return urllib.request.urlopen(request)
except Exception as e:
if "certificate verify failed" in str(e):
logging.error(
f"{str(e)} - you may need to install python certificates, see https://stackoverflow.com/questions/27835619/urllib-and-ssl-certificate-verify-failed-error"
)
sys.exit(1)
if i == max_attempts - 1:
raise e
else:
logging.info(f"Retrying error: {str(e)}")
time.sleep(1)


def readFileExitOnFailure(path, file_description):
try:
with open(path, "r") as f:
return f.read()
except FileNotFoundError:
logging.error(f"{file_description.capitalize()} file not found: {path}")
except IsADirectoryError:
logging.error(f"Invalid {file_description} file: {path} is a directory")
except PermissionError:
logging.error(f"Permission denied to read {file_description} file: {path}")
sys.exit(1)


def loadAPIKey(api_key_arg):
source = ""
if api_key_arg:
api_key_arg = api_key_arg.strip()
source = f"file {api_key_arg}"
key = readFileExitOnFailure(api_key_arg, "API key").strip()
else:
if "RBLX_OC_API_KEY" not in os.environ:
logging.error(
"API key needed. Either provide the --api-key option or set the RBLX_OC_API_KEY environment variable."
)
sys.exit(1)
source = "environment variable RBLX_OC_API_KEY"
key = os.environ["RBLX_OC_API_KEY"].strip()

try:
base64.b64decode(key, validate=True)
return key
except Exception as e:
logging.error(
f"API key appears invalid (not valid base64, loaded from {source}): {str(e)}"
)
sys.exit(1)


def createTask(api_key, script, universe_id, place_id, place_version):
headers = {"Content-Type": "application/json", "x-api-key": api_key}
data = {"script": script}
url = f"https://apis.roblox.com/cloud/v2/universes/{universe_id}/places/{place_id}/"
if place_version:
url += f"versions/{place_version}/"
url += "luau-execution-session-tasks"

try:
response = makeRequest(url, headers=headers, body=json.dumps(data))
except urllib.error.HTTPError as e:
logging.error(f"Create task request failed, response body:\n{e.fp.read()}")
sys.exit(1)

task = json.loads(response.read())
return task


def pollForTaskCompletion(api_key, path):
headers = {"x-api-key": api_key}
url = f"https://apis.roblox.com/cloud/v2/{path}"

logging.info("Waiting for task to finish...")

while True:
try:
response = makeRequest(url, headers=headers)
except urllib.error.HTTPError as e:
logging.error(f"Get task request failed, response body:\n{e.fp.read()}")
sys.exit(1)

task = json.loads(response.read())
if task["state"] != "PROCESSING":
sys.stderr.write("\n")
sys.stderr.flush()
return task
else:
sys.stderr.write(".")
sys.stderr.flush()
time.sleep(3)


def getTaskLogs(api_key, task_path):
headers = {"x-api-key": api_key}
url = f"https://apis.roblox.com/cloud/v2/{task_path}/logs"

try:
response = makeRequest(url, headers=headers)
except urllib.error.HTTPError as e:
logging.error(f"Get task logs request failed, response body:\n{e.fp.read()}")
sys.exit(1)

logs = json.loads(response.read())
messages = logs["luauExecutionSessionTaskLogs"][0]["messages"]
return "".join([m + "\n" for m in messages])


def handleLogs(task, log_output_file_path, api_key):
logs = getTaskLogs(api_key, task["path"])
if logs:
if log_output_file_path:
with open(log_output_file_path, "w") as f:
f.write(logs)
logging.info(f"Task logs written to {log_output_file_path}")
else:
logging.info(f"Task logs:\n{logs.strip()}")
else:
logging.info("The task did not produce any logs")


def handleSuccess(task, output_path):
output = task["output"]
if output["results"]:
if output_path:
with open(output_path, "w") as f:
f.write(json.dumps(output["results"]))
logging.info(f"Task results written to {output_path}")
else:
logging.info("Task output:")
print(json.dumps(output["results"]))
else:
logging.info("The task did not return any results")


def handleFailure(task):
logging.error(f'Task failed, error:\n{json.dumps(task["error"])}')


if __name__ == "__main__":
logging.basicConfig(
format="[%(asctime)s] [%(name)s] [%(levelname)s]: %(message)s",
level=logging.INFO,
)

args = parseArgs()

api_key = loadAPIKey(args.api_key)

waiting_msg_printed = False
prev_script_hash = None
while True:
script = readFileExitOnFailure(args.script_file, "script")
script_hash = hashlib.sha256(script.encode("utf8")).hexdigest()

if prev_script_hash is not None and script_hash == prev_script_hash:
if not waiting_msg_printed:
logging.info("Waiting for changes to script file...")
waiting_msg_printed = True
time.sleep(1)
continue

if prev_script_hash is not None:
logging.info("Detected change to script file, submitting new task")

prev_script_hash = script_hash
waiting_msg_printed = False

task = createTask(
api_key, script, args.universe, args.place, args.place_version
)
logging.info(f"Task created, path: {task['path']}")

task = pollForTaskCompletion(api_key, task["path"])
logging.info(f'Task is now in {task["state"]} state')

handleLogs(task, args.log_output, api_key)
if task["state"] == "COMPLETE":
handleSuccess(task, args.output)
else:
handleFailure(task)

if not args.continuous:
break
Loading

0 comments on commit 4cd6fd6

Please sign in to comment.