refactor: extract get previous publish run into separate script, run it and reuse by discord/rss/changelog steps (#45690)

* refactor: extract get previous publish run into separate script, run it and reuse by discord/rss/changelog steps

* refactor: reuse changelog step

* refactor: reuse code for getting core env variables

* refactor: remove output rewrite protection,

* refactor: dry-run mode added for publish workflow, added tag marking

* fix: restore executable bit on Tools scripts run directly by the publish workflow

* feat: announce dry run mode in the publish workflow log

* refactor: use sha for ss14-changelog update call

* refactor: now actually fix last publish sha usage for changelog update

* refactor: force PR creation run even in dry-run

* refactor: remove support email from publish

* refactor: remove duplicate message

* refactor: fix wording for skipping `Create PR from stable to master`
This commit is contained in:
Fildrance 2026-09-21 20:20:11 +00:00 • committed by GitHub
parent cc588dc278
commit dad5ff6516
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 339 additions and 103 deletions

View file

@ -6,14 +6,35 @@ concurrency:
on:
workflow_dispatch:
inputs:
dry_run:
description: Simulate a publish without any external side effects (no push, no PR, no CDN/Discord/RSS uploads, fail at the end to not clog list of successfu PRs because workflow relies on that).
required: false
default: false
type: boolean
version:
description: Optional version tag to apply to the released commit and push (used as-is, e.g. 'v2026.08.0'); adds no tag when empty.
required: false
type: string
# schedule:
# - cron: '0 10 * * *'
jobs:
build:
runs-on: ubuntu-latest
env:
DRY_RUN: ${{ github.event.inputs.dry_run == 'true' }}
steps:
- name: Announce dry run
if: ${{ github.event.inputs.dry_run == 'true' }}
run: |
echo "################################################################################"
echo "# DRY RUN IS RUNNING #"
echo "# Nothing will be committed, pushed, tagged, uploaded or posted. #"
echo "# This run is expected to fail at the end and never counts as a publish. #"
echo "################################################################################"
- name: Fail if we are attempting to run on the master branch
if: ${{GITHUB.REF_NAME == 'master' && github.repository == 'space-wizards/space-station-14'}}
run: exit 1
@ -39,40 +60,76 @@ jobs:
- name: Install dependencies
run: dotnet restore
# - name: Update changelog
# run: |
# dotnet tool install --global SpaceWizards.ChangelogTool --version 1.0.7
# ss14-changelog update -d Resources/Changelog
# env:
# REPO: ${{ github.repository }}
# BRANCH: stable
# CHANGELOG_REPO_PATH: Resources/Changelog
# EXTRA_CATEGORIES: Admin,Maps,Rules
# GITHUB_TOKEN: ${{ secrets.CL_ACCESS_TOKEN }}
- name: Get last publish SHA
id: last-publish
run: Tools/actions_get_last_publish_sha.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# - name: Commit and push changelog changes
# id: changelog
# run: |
# git config user.name "Space-Wizards-Bot"
# git config user.email "support@spacestation14.com"
# if [ -n "$(git status --porcelain Resources/Changelog)" ]; then
# git add Resources/Changelog
# git commit -m "Update changelog"
# git remote set-url origin "https://x-access-token:${CL_ACCESS_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
# git push origin "HEAD:${GITHUB_REF_NAME}"
# echo "committed=true" >> "$GITHUB_OUTPUT"
# fi
# env:
# CL_ACCESS_TOKEN: ${{ secrets.CL_ACCESS_TOKEN }}
- name: Update changelog
run: |
dotnet tool install --global SpaceWizards.ChangelogTool --version 1.0.8
ss14-changelog update -d Resources/Changelog -s ${LAST_PUBLISH_SHA}
env:
REPO: ${{ github.repository }}
BRANCH: stable
CHANGELOG_REPO_PATH: Resources/Changelog
EXTRA_CATEGORIES: Admin,Maps,Rules
GITHUB_TOKEN: ${{ secrets.CL_ACCESS_TOKEN }}
LAST_PUBLISH_SHA: ${{ steps.last-publish.outputs.last_publish_sha }}
# - name: Create PR from stable to master
# if: steps.changelog.outputs.committed == 'true'
# env:
# GH_TOKEN: ${{ secrets.CL_ACCESS_TOKEN }}
# run: |
# if [ -z "$(gh pr list --repo "$GITHUB_REPOSITORY" --head stable --base master --state open --json number --jq '.[].number')" ]; then
# gh pr create --repo "$GITHUB_REPOSITORY" --base master --head stable --title "Update changelog" --body "Sync from release to master."
# fi
- name: Commit and push changelog changes
id: changelog
run: |
git config user.name "Space-Wizards-Bot"
git config user.email "bot@spacestation14.com"
if [ -n "$(git status --porcelain Resources/Changelog)" ]; then
git add Resources/Changelog
if [ "$DRY_RUN" = "true" ]; then
echo "DRY-RUN: staged files:"
git diff --cached --name-only | sed 's/^/DRY-RUN: /'
if git commit -m "Update changelog"; then
git push --dry-run "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}" || true
# Roll back the throwaway commit. Required by following step dry-run mode.
git reset --soft HEAD~1
fi
else
git commit -m "Update changelog"
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push origin "HEAD:${GITHUB_REF_NAME}"
echo "committed=true" >> "$GITHUB_OUTPUT"
fi
fi
env:
GH_TOKEN: ${{ secrets.CL_ACCESS_TOKEN }}
- name: Tag version commit
if: ${{ github.event.inputs.version != '' }}
env:
GH_TOKEN: ${{ secrets.CL_ACCESS_TOKEN }}
VERSION_TAG: ${{ github.event.inputs.version }}
run: |
HEAD_SHA=$(git rev-parse HEAD)
if [ "$DRY_RUN" = "true" ]; then
echo "DRY-RUN: would create tag '${VERSION_TAG}' at ${HEAD_SHA} and push it."
else
git tag "${VERSION_TAG}"
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${VERSION_TAG}"
fi
- name: Create PR from stable to master
env:
GH_TOKEN: ${{ secrets.CL_ACCESS_TOKEN }}
run: |
if [ -z "$(gh pr list --repo "$GITHUB_REPOSITORY" --head stable --base master --state open --json number --jq '.[].number')" ]; then
if [ "$DRY_RUN" = "true" ]; then
gh pr create --dry-run --repo "$GITHUB_REPOSITORY" --base master --head stable --title "Stable to master sync after release" --body "Sync from release to master." || echo "::warning::DRY-RUN: PR would not be created (see gh output above)."
else
gh pr create --repo "$GITHUB_REPOSITORY" --base master --head stable --title "Update changelog" --body "Sync from release to master."
fi
else
echo "Merge from stable to master PR already exist, skipping."
fi
- name: Build Packaging
run: dotnet build Content.Packaging --configuration Release --no-restore /m
@ -94,9 +151,17 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.CHANGELOG_DISCORD_WEBHOOK }}
LAST_PUBLISH_SHA: ${{ steps.last-publish.outputs.last_publish_sha }}
- name: Publish changelog (RSS)
if: ${{ github.event.inputs.dry_run != 'true' }}
continue-on-error: true
run: Tools/actions_changelog_rss.py
env:
CHANGELOG_RSS_KEY: ${{ secrets.CHANGELOG_RSS_KEY }}
- name: Finalize dry run (not a publish)
if: ${{ github.event.inputs.dry_run == 'true' }}
run: |
echo "::error::Dry run completed - nothing was actually published. Exiting non-zero so this run is never recorded as a successful publish and is excluded from future 'Get last publish SHA' lookups."
exit 1

View file

@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Shared GitHub Actions API helpers for getting workflow information.
"""
import os
import re
from typing import Any, Iterable
import requests
GITHUB_API_URL = os.environ.get("GITHUB_API_URL", "https://api.github.com")
def make_github_session(github_token: str) -> requests.Session:
session = requests.Session()
session.headers["Authorization"] = f"Bearer {github_token}"
session.headers["Accept"] = "application/vnd.github+json"
session.headers["X-GitHub-Api-Version"] = "2022-11-28"
return session
def get_required_github_env() -> tuple[str, str, str]:
"""Read and validate the GitHub Actions env vars needed for the API."""
github_repository = os.environ.get("GITHUB_REPOSITORY")
github_run = os.environ.get("GITHUB_RUN_ID")
github_token = os.environ.get("GITHUB_TOKEN")
if not github_repository:
raise RuntimeError("GITHUB_REPOSITORY is not set")
if not github_run:
raise RuntimeError("GITHUB_RUN_ID is not set")
if not github_token:
raise RuntimeError("GITHUB_TOKEN is not set")
return github_repository, github_run, github_token
def get_most_recent_workflow(
sess: requests.Session, github_repository: str, github_run: str
) -> Any:
workflow_run = get_current_run(sess, github_repository, github_run)
past_runs = get_past_runs(sess, workflow_run)
for run in past_runs:
return run
raise RuntimeError("Could not find a previous successful workflow run")
def get_current_run(
sess: requests.Session, github_repository: str, github_run: str
) -> Any:
resp = sess.get(
f"{GITHUB_API_URL}/repos/{github_repository}/actions/runs/{github_run}"
)
resp.raise_for_status()
run = resp.json()
for key in ("id", "created_at", "workflow_url"):
if key not in run:
raise RuntimeError(
f"GitHub API response for current run is missing '{key}'"
)
return run
def get_past_runs(sess: requests.Session, current_run: Any) -> Iterable[Any]:
"""
Get all successful workflow runs before our current one.
"""
params = {
"status": "success",
"created": f"<={current_run['created_at']}",
"per_page": 100,
}
url = f"{current_run['workflow_url']}/runs"
while url:
resp = sess.get(url, params=params)
resp.raise_for_status()
runs = resp.json()
if "workflow_runs" not in runs:
raise RuntimeError(
"GitHub API response for past runs is missing 'workflow_runs'"
)
for run in runs["workflow_runs"]:
# First past successful run that isn't our current run.
if run["id"] == current_run["id"]:
continue
yield run
next_url = resp.links.get("next", {}).get("url")
if not next_url:
break
url = next_url
params = None
def get_last_publish_sha(
sess: requests.Session, github_repository: str, github_run: str
) -> str:
"""
Get the head commit SHA of the most recent successful workflow run.
Validates that the found run actually has a head commit id and that the
value looks like a commit SHA before returning it.
"""
most_recent = get_most_recent_workflow(sess, github_repository, github_run)
head_commit = most_recent.get("head_commit")
last_sha = head_commit.get("id") if head_commit else None
if not last_sha:
raise RuntimeError(
f"Workflow run {most_recent.get('id')} has no head_commit id"
)
print(f"Last successful publish job was {most_recent.get('id')}: {last_sha}")
return last_sha

View file

@ -17,10 +17,12 @@ import requests
import yaml
import time
import actions_changelog_github
DEBUG = os.environ.get("SS14_CHANGELOG_DEBUG", "").lower() in {"1", "true", "yes"}
DRY_RUN = os.environ.get("DRY_RUN", "").lower() in {"1", "true", "yes"}
DEBUG_CHANGELOG_FILE_OLD = Path("Resources/Changelog/Old.yml")
DEBUG_DISCORD_DUMP_FILE = Path("Resources/Changelog/DiscordDebug.md")
GITHUB_API_URL = os.environ.get("GITHUB_API_URL", "https://api.github.com")
# https://discord.com/developers/docs/resources/webhook
DISCORD_SPLIT_LIMIT = 2000
@ -38,7 +40,7 @@ ChangelogEntry = dict[str, Any]
def main():
if not DEBUG and not DISCORD_WEBHOOK_URL:
if not DEBUG and not DRY_RUN and not DISCORD_WEBHOOK_URL:
print("No discord webhook URL found, skipping discord send")
return
@ -62,78 +64,31 @@ def main():
dump_debug_markdown(message_lines)
return
if DRY_RUN:
log_message_lines_dry_run(message_lines)
return
send_message_lines(message_lines)
def get_most_recent_workflow(
sess: requests.Session, github_repository: str, github_run: str
) -> Any:
workflow_run = get_current_run(sess, github_repository, github_run)
past_runs = get_past_runs(sess, workflow_run)
for run in past_runs:
return run
raise RuntimeError("Could not find a previous successful workflow run")
def get_current_run(
sess: requests.Session, github_repository: str, github_run: str
) -> Any:
resp = sess.get(
f"{GITHUB_API_URL}/repos/{github_repository}/actions/runs/{github_run}"
)
resp.raise_for_status()
return resp.json()
def get_past_runs(sess: requests.Session, current_run: Any) -> Iterable[Any]:
"""
Get all successful workflow runs before our current one.
"""
params = {
"status": "success",
"created": f"<={current_run['created_at']}",
"per_page": 100,
}
url = f"{current_run['workflow_url']}/runs"
while url:
resp = sess.get(url, params=params)
resp.raise_for_status()
for run in resp.json()["workflow_runs"]:
# First past successful run that isn't our current run.
if run["id"] == current_run["id"]:
continue
yield run
next_url = resp.links.get("next", {}).get("url")
if not next_url:
break
url = next_url
params = None
def get_last_changelog() -> str:
github_repository = os.environ["GITHUB_REPOSITORY"]
github_run = os.environ["GITHUB_RUN_ID"]
github_token = os.environ["GITHUB_TOKEN"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {github_token}"
session.headers["Accept"] = "application/vnd.github+json"
session.headers["X-GitHub-Api-Version"] = "2022-11-28"
most_recent = get_most_recent_workflow(session, github_repository, github_run)
last_sha = most_recent["head_commit"]["id"]
print(f"Last successful publish job was {most_recent['id']}: {last_sha}")
last_changelog_stream = get_last_changelog_by_sha(
session, last_sha, github_repository
github_repository, github_run, github_token = (
actions_changelog_github.get_required_github_env()
)
return last_changelog_stream
session = actions_changelog_github.make_github_session(github_token)
# If a previous workflow step already computed the last successful publish's
# SHA, reuse it instead of querying the GitHub Actions API for the same info.
last_sha = os.environ.get("LAST_PUBLISH_SHA")
if last_sha:
print(f"Using last publish SHA from environment: {last_sha}")
else:
last_sha = actions_changelog_github.get_last_publish_sha(
session, github_repository, github_run
)
return get_last_changelog_by_sha(session, last_sha, github_repository)
def get_last_changelog_by_sha(
@ -148,7 +103,7 @@ def get_last_changelog_by_sha(
headers = {"Accept": "application/vnd.github.raw"}
resp = sess.get(
f"{GITHUB_API_URL}/repos/{github_repository}/contents/{CHANGELOG_FILE}",
f"{actions_changelog_github.GITHUB_API_URL}/repos/{github_repository}/contents/{CHANGELOG_FILE}",
headers=headers,
params=params,
)
@ -320,6 +275,22 @@ def send_message_lines(message_lines: list[str]):
send_discord_webhook(chunks[-1])
def log_message_lines_dry_run(message_lines: list[str]):
"""Log the Discord messages that would be sent, without sending them."""
chunks = split_message_lines(message_lines)
if not chunks:
print("[DRY RUN] No changelog entries to publish.")
return
print(f"[DRY RUN] Would send {len(chunks)} Discord message(s). Dumping contents:")
for i, chunk_lines in enumerate(chunks, start=1):
print(f"[DRY RUN] --- Discord message {i}/{len(chunks)} ---")
for line in chunk_lines:
print(line, end="")
print()
if __name__ == "__main__":
try:
main()

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Computes the SHA of the last successful GitHub Actions workflow run (on any branch).
Stores found SHA into last_publish_sha output variable for later steps to use.
remark: if previous launch was done on branch that was deleted and commits from there were
not added to tree - this WILL FAIL.
"""
import os
import sys
import actions_changelog_github
def get_last_publish_sha() -> str:
github_repository, github_run, github_token = (
actions_changelog_github.get_required_github_env()
)
session = actions_changelog_github.make_github_session(github_token)
return actions_changelog_github.get_last_publish_sha(
session, github_repository, github_run
)
def main():
github_output = os.environ.get("GITHUB_OUTPUT")
# If the value was already provided via the environment, propagate it to the
# step output instead of recomputing it.
last_sha = os.environ.get("LAST_PUBLISH_SHA")
if last_sha:
print(f"LAST_PUBLISH_SHA is already set, using it: {last_sha}")
if github_output:
with open(github_output, "a") as f:
f.write(f"last_publish_sha={last_sha}\n")
return
last_sha = get_last_publish_sha()
if github_output:
# Write to the action step output so later steps can reuse it.
with open(github_output, "a") as f:
f.write(f"last_publish_sha={last_sha}\n")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Failed to compute last publish SHA: {e}", file=sys.stderr)
exit(1)

View file

@ -6,7 +6,7 @@ import os
import subprocess
from typing import Iterable
PUBLISH_TOKEN = os.environ["PUBLISH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "").lower() in ("1", "true", "yes")
VERSION = os.environ["GITHUB_SHA"]
RELEASE_DIR = "release"
@ -18,6 +18,23 @@ RELEASE_DIR = "release"
ROBUST_CDN_URL = "https://wizards.cdn.spacestation14.com/"
FORK_ID = "wizards"
def get_publish_token() -> str:
try:
return os.environ["PUBLISH_TOKEN"]
except KeyError:
raise RuntimeError("PUBLISH_TOKEN is not set")
def simulate_publish(fork_id: str, version: str) -> None:
print(f"[DRY RUN] Would publish version {version} to Robust.CDN fork '{fork_id}'")
print(f"[DRY RUN] engine version: {get_engine_version()}")
files = list(get_files_to_publish())
print(f"[DRY RUN] files to upload ({len(files)}):")
for file in files:
print(f"[DRY RUN] - {file}")
print("[DRY RUN] Skipping all network calls to Robust.CDN.")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--fork-id", default=FORK_ID)
@ -25,9 +42,14 @@ def main():
args = parser.parse_args()
fork_id = args.fork_id
if DRY_RUN:
simulate_publish(fork_id, VERSION)
return
session = requests.Session()
session.headers = {
"Authorization": f"Bearer {PUBLISH_TOKEN}",
"Authorization": f"Bearer {get_publish_token()}",
}
print(f"Starting publish on Robust.Cdn for version {VERSION}")