From dad5ff6516f545db78de020bd674345aa6cdb80f Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 21 Sep 2026 20:20:11 +0000 Subject: [PATCH] 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` --- .github/workflows/publish.yml | 129 ++++++++++++++++----- Tools/actions_changelog_github.py | 124 ++++++++++++++++++++ Tools/actions_changelogs_since_last_run.py | 109 +++++++---------- Tools/actions_get_last_publish_sha.py | 54 +++++++++ Tools/publish_multi_request.py | 26 ++++- 5 files changed, 339 insertions(+), 103 deletions(-) create mode 100644 Tools/actions_changelog_github.py create mode 100755 Tools/actions_get_last_publish_sha.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8603cf1af9..f25e487d04 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/Tools/actions_changelog_github.py b/Tools/actions_changelog_github.py new file mode 100644 index 0000000000..faad1711d6 --- /dev/null +++ b/Tools/actions_changelog_github.py @@ -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 diff --git a/Tools/actions_changelogs_since_last_run.py b/Tools/actions_changelogs_since_last_run.py index ebb4e30820..4c1fd45d33 100755 --- a/Tools/actions_changelogs_since_last_run.py +++ b/Tools/actions_changelogs_since_last_run.py @@ -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() diff --git a/Tools/actions_get_last_publish_sha.py b/Tools/actions_get_last_publish_sha.py new file mode 100755 index 0000000000..4b89345d1c --- /dev/null +++ b/Tools/actions_get_last_publish_sha.py @@ -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) diff --git a/Tools/publish_multi_request.py b/Tools/publish_multi_request.py index e67f9e40cd..b1fb28a563 100755 --- a/Tools/publish_multi_request.py +++ b/Tools/publish_multi_request.py @@ -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}")