Compare commits

...

7 Commits

Author SHA1 Message Date
249d6897fa Merge branch 'main' into fix/issue75-class-D-gh-api-to-gitea-rest 2026-05-08 02:46:43 +00:00
31d6debba8 Merge branch 'main' into fix/issue75-class-D-gh-api-to-gitea-rest 2026-05-08 01:27:48 +00:00
7ec87ee2c6 Merge branch 'main' into fix/issue75-class-D-gh-api-to-gitea-rest 2026-05-08 00:54:20 +00:00
2ab533af58 Merge branch 'main' into fix/issue75-class-D-gh-api-to-gitea-rest 2026-05-08 00:20:55 +00:00
e58d7c17ad Merge branch 'main' into fix/issue75-class-D-gh-api-to-gitea-rest 2026-05-07 23:39:26 +00:00
85aa3eb4a9 Merge branch 'main' into fix/issue75-class-D-gh-api-to-gitea-rest 2026-05-07 22:53:19 +00:00
aa6458b42d fix(ci): replace gh api REST passthroughs with Gitea-compatible shapes (#75 class D)
Part of the post-#66 sweep to remove `gh` CLI dependencies that fail
silently against Gitea (which exposes /api/v1 only — no GraphQL → 405,
no /api/v3 → 404). Class D covers `gh api` REST passthroughs that
either have a Gitea v1 equivalent at a different path/shape or no
equivalent at all.

Three files in this class, each with a different fix shape because
each underlying Gitea capability is different:

`auto-promote-on-e2e.yml` (compute SHA ancestry):
  Old: `gh api repos/.../compare/A...B` returning `.status`
       (ahead|behind|identical|diverged).
  Gitea: `/api/v1/repos/.../compare/A...B` accepts only branch / tag
       refs — full commit SHAs return `BaseNotExist`. So even a
       "translate the URL" rewrite would fail. Verified empirically
       2026-05-07: branches/tags work, SHAs don't.
  Fix:  Add `actions/checkout@v6 fetch-depth=200` + use `git merge-
       base --is-ancestor` locally. Exact same four-bucket semantics
       (ahead | behind | diverged | error), zero cross-host API
       dependency. Same pattern PR #66 used for auto-sync. The 200-
       commit depth comfortably covers any realistic divergence
       between :latest and a candidate retag (promotes are minutes
       apart, not hundreds of commits).

`ci.yml` (canvas-deploy-reminder commit comment):
  Old: `gh api -X POST repos/.../commits/{sha}/comments` posting a
       deploy-reminder body for the operator.
  Gitea: NO commit-comments endpoint exists — `/repos/.../commits/
       {sha}/comments` returns 404 (verified 2026-05-07). Gitea only
       exposes `/commits/{sha}/statuses` for commit-level surface,
       which is the wrong shape for a free-form reminder.
  Fix:  Drop the API call. Write the reminder body to
       `$GITHUB_STEP_SUMMARY` instead. The reminder is entirely
       operator-facing and is just as discoverable on the run summary
       page (which an operator naturally lands on when they need to
       action a deploy). Commit comments were a stale UI artefact of
       the GitHub era, not a load-bearing automation surface.
  Permission: drop `contents: write` (no longer needed) → `read`,
       smallest scope per least-privilege.

`check-merge-group-trigger.yml` (merge_group: trigger linter):
  Old: `gh api .../branches/staging/protection/required_status_checks`
       reading the contexts list, then walking workflow files.
  Gitea: branch-protection API is at /api/v1/repos/.../branch_
       protections/{name} (different path) with `status_check_
       contexts` (different field name) — but the entire workflow
       only existed to lint that workflows producing a required
       check declare a `merge_group:` trigger, which is needed
       because GitHub's merge queue dead-locks at AWAITING_CHECKS
       when the trigger is missing. Gitea has NO merge queue, NO
       gh-readonly-queue/... ref shape, NO merge_group event
       semantics. The dead-lock pattern this linter catches cannot
       occur on Gitea by construction.
  Fix:  Convert to no-op stub (same pattern as the CodeQL stub
       landed in PR #51). Workflow name + trigger surface preserved
       so any external referrer (none confirmed via the 2026-05-07
       branch-protection audit) keeps resolving. Re-enable path
       documented in the file header for if/when Gitea grows a
       merge queue.

curl invocation pattern: `curl --fail-with-body -sS` (NOT `-fsS` —
the two short-fail flags are mutually exclusive in modern curl).

Token model: workflows continue to use act_runner's GITHUB_TOKEN
where they still need API access (`auto-promote-on-e2e.yml`'s
checkout uses the runner's default token; `ci.yml` no longer needs
any API auth for the deploy-reminder step; `check-merge-group-
trigger.yml` no longer makes any API calls).

Verification:
- YAML syntax validates for all three files.
- Live curl against Gitea confirms `/compare/A...B` accepts branch
  refs (200, total_commits=N) and refuses full SHAs (404,
  BaseNotExist) — justifying the local-git approach.
- `/repos/.../commits/{sha}/comments` confirmed 404 on Gitea.
- `git merge-base --is-ancestor` exit-code semantics match the
  GitHub compare API status semantics exactly: ahead = current is
  ancestor of target; behind = target is ancestor of current;
  diverged = neither.

Closes part of #75. Class A landed in #80; class F (gh run list →
no Gitea workflow-runs API at all) lands in a separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:35:59 -07:00
3 changed files with 194 additions and 153 deletions

View File

@ -309,23 +309,46 @@ jobs:
echo " ok: $tag exists"
done
- name: Ancestry check — refuse to promote :latest backwards
# #2244: workflow_run completions arrive in arbitrary order. If
# SHA-A and SHA-B both reach main within ~10 min and SHA-B's E2E
# completes before SHA-A's, this workflow can fire for SHA-A
# AFTER it already promoted SHA-B → :latest goes backwards. The
# orphan-reconciler "next run corrects it" doesn't apply: there's
# no auto-corrective re-promote, :latest stays wrong until the
# next main push lands.
- name: Checkout for local ancestry compute
# #2244: workflow_run completions arrive in arbitrary order. The
# ancestry check below uses `git merge-base --is-ancestor` to
# compare CURRENT_REVISION (read off the live :latest image) and
# TARGET_SHA (this run's SHA) — both full commit SHAs.
#
# Why a local checkout, not the Gitea compare API:
#
# Gitea v1.22's `/api/v1/repos/.../compare/A...B` does NOT accept
# full commit SHAs as either side — it returns
# {"total_commits":null, "message":"BaseNotExist"}
# for any non-branch / non-tag ref (verified 2026-05-07, issue
# #75). Branch + tag refs work, but ancestry between two
# arbitrary historical commits does not. The previous version
# called GitHub's `gh api repos/.../compare/A...B` which DOES
# accept SHAs and returns `.status: ahead|behind|identical|
# diverged` — that surface simply doesn't exist on Gitea.
#
# Local git is exact, fast (depth=200 covers any realistic
# divergence between :latest and a candidate retag — promote
# cycles are minutes, not hundreds of commits), and removes the
# cross-host API dependency entirely.
if: steps.gate.outputs.proceed == 'true' && github.event_name != 'workflow_dispatch'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Need enough history to resolve both CURRENT_REVISION and
# TARGET_SHA + their merge-base. 200 covers ~a week of main
# activity at the current commit cadence. Bump if a future
# cron pause lets :latest fall further behind.
fetch-depth: 200
- name: Ancestry check — refuse to promote :latest backwards
# Detection: read current :latest's `org.opencontainers.image.revision`
# label (set by publish-workspace-server-image.yml at build time)
# and ask the GitHub compare API whether the candidate SHA is
# ahead-of / identical-to / behind / diverged-from current.
# Hard-fail on `behind` and `diverged` per the approved design —
# silent-bypass is the class we're moving away from. Workflow
# goes red, oncall sees it, operator decides how to recover
# (manual dispatch with the right SHA, force-promote, etc.).
# and ask local git whether the candidate SHA is ahead-of /
# identical-to / behind / diverged-from current. Hard-fail on
# `behind` and `diverged` per the approved design — silent-
# bypass is the class we're moving away from. Workflow goes red,
# oncall sees it, operator decides how to recover (manual
# dispatch with the right SHA, force-promote, etc.).
#
# Manual dispatch skips this check — operator override semantics
# match the gate-check step above.
@ -337,7 +360,6 @@ jobs:
if: steps.gate.outputs.proceed == 'true' && github.event_name != 'workflow_dispatch'
id: ancestry
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TARGET_SHA: ${{ steps.sha.outputs.full }}
run: |
@ -369,13 +391,44 @@ jobs:
exit 0
fi
# Ask GitHub which side of the merge graph TARGET_SHA sits on
# relative to CURRENT_REVISION. Returns one of: ahead | identical
# | behind | diverged. Network or auth errors collapse to "error"
# via the explicit fallback so the case below always matches.
STATUS=$(gh api \
"repos/${REPO}/compare/${CURRENT_REVISION}...${TARGET_SHA}" \
--jq '.status' 2>/dev/null || echo "error")
# Compute ancestry locally with git. The four buckets match
# GitHub's compare API status semantics exactly:
#
# ahead — target reaches current via parent pointers
# AND current does not reach target. I.e. target
# is a descendant of current → :latest moves
# forward, allow.
# identical — caught above by SHA-equality early-return.
# behind — current reaches target via parent pointers
# AND target does not reach current. I.e. target
# is an ancestor of current → moving :latest
# backwards (the #2244 race), block.
# diverged — neither reaches the other. Force-push or
# history rewrite, block.
#
# `git merge-base --is-ancestor X Y` exits 0 iff X is an
# ancestor of Y. Both calls are cheap (constant-ish in depth,
# which we bounded at 200 above).
#
# Both SHAs MUST be reachable in the runner's clone. If
# either rev-parse fails (e.g. the depth=200 we fetched isn't
# deep enough for an unusually old :latest revision), fall
# back to "error" — the previous version's `error` branch
# exits 1 and surfaces an explicit failure for operator
# action, same as a network blip in the old GitHub version.
if ! git rev-parse --verify --quiet "${CURRENT_REVISION}^{commit}" >/dev/null \
|| ! git rev-parse --verify --quiet "${TARGET_SHA}^{commit}" >/dev/null; then
STATUS="error"
elif git merge-base --is-ancestor "$CURRENT_REVISION" "$TARGET_SHA" 2>/dev/null; then
# CURRENT is ancestor of TARGET → TARGET is ahead.
STATUS="ahead"
elif git merge-base --is-ancestor "$TARGET_SHA" "$CURRENT_REVISION" 2>/dev/null; then
# TARGET is ancestor of CURRENT → TARGET is behind.
STATUS="behind"
else
# Neither reaches the other → divergent.
STATUS="diverged"
fi
echo "ancestry compare ${CURRENT_REVISION:0:7} → ${TARGET_SHA:0:7}: $STATUS"
@ -384,10 +437,6 @@ jobs:
echo "decision=ahead" >> "$GITHUB_OUTPUT"
echo "::notice::Target ${TARGET_SHA:0:7} is ahead of current :latest (${CURRENT_REVISION:0:7}) — proceeding with retag"
;;
identical)
echo "decision=identical" >> "$GITHUB_OUTPUT"
echo "::notice::Target identical to :latest — retag will be a no-op"
;;
behind)
echo "decision=behind" >> "$GITHUB_OUTPUT"
{
@ -397,7 +446,7 @@ jobs:
echo "|---|---|"
echo "| Target SHA | \`$TARGET_SHA\` |"
echo "| Current :latest revision | \`$CURRENT_REVISION\` |"
echo "| GitHub compare status | \`behind\` |"
echo "| Ancestry compute | \`behind\` (target is an ancestor of :latest) |"
echo
echo "This guard catches the workflow_run-completion-order race (#2244):"
echo "two rapid main pushes whose E2Es complete out-of-order can otherwise"
@ -418,7 +467,7 @@ jobs:
echo "|---|---|"
echo "| Target SHA | \`$TARGET_SHA\` |"
echo "| Current :latest revision | \`$CURRENT_REVISION\` |"
echo "| GitHub compare status | \`diverged\` |"
echo "| Ancestry compute | \`diverged\` (neither commit reaches the other) |"
echo
echo "Likely cause: force-push rewrote main's history, leaving the previous"
echo "\`:latest\` revision orphaned. Needs human review before \`:latest\` advances."
@ -428,9 +477,10 @@ jobs:
error|*)
echo "decision=error" >> "$GITHUB_OUTPUT"
{
echo "## ❌ Auto-promote aborted — ancestry-check API error"
echo "## ❌ Auto-promote aborted — ancestry-check error"
echo
echo "\`gh api repos/${REPO}/compare/${CURRENT_REVISION}...${TARGET_SHA}\` returned unexpected status: \`$STATUS\`"
echo "Could not resolve both \`$CURRENT_REVISION\` and \`$TARGET_SHA\` in the runner clone (status=\`$STATUS\`)."
echo "Likely cause: \`fetch-depth: 200\` did not reach \`$CURRENT_REVISION\` — increase the fetch depth in this workflow."
echo
echo "Manual dispatch with the target sha bypasses this check."
} >> "$GITHUB_STEP_SUMMARY"

View File

@ -1,19 +1,64 @@
name: Check merge_group trigger on required workflows
# Pre-merge guard against the deadlock pattern where a workflow whose
# check is in `required_status_checks` lacks a `merge_group:` trigger.
# Without it, GitHub merge queue stalls forever in AWAITING_CHECKS
# because the required check can't fire on `gh-readonly-queue/...` refs.
# Stub workflow — Gitea Actions has no merge queue (post-2026-05-06
# SCM migration off GitHub).
#
# This workflow:
# 1. Lists required status checks on the branch protection rule for `staging`
# 2. For each required check, finds the workflow that produces it (by job
# name match)
# 3. Fails if any such workflow lacks `merge_group:` in its triggers
# Why this is a stub, not a real linter:
#
# Reasoning for staging-only: main has its own CI gating model (PR review),
# but staging is what the merge queue runs on, so it's the trigger that
# matters.
# 1. The original workflow existed exclusively to lint that any
# workflow producing a required status check on the staging branch
# also declares a `merge_group:` trigger. Without that trigger,
# GitHub's merge queue dead-locks at AWAITING_CHECKS forever
# because the required check can't fire on `gh-readonly-queue/...`
# refs (a GitHub-merge-queue-specific ref shape).
#
# 2. Gitea has no merge queue. There is no `gh-readonly-queue/...`
# ref shape on Gitea. There is no `merge_group` event type in the
# Gitea Actions trigger surface. The deadlock pattern this linter
# catches CANNOT occur on the new SCM, by construction.
#
# 3. The original linter additionally called
# `gh api repos/.../branches/staging/protection/required_status_checks`,
# which on Gitea returns 404 — Gitea's branch-protection API lives
# at `/repos/{owner}/{repo}/branch_protections/{name}` (different
# path), and the response shape uses `status_check_contexts`
# instead of `contexts`. So the linter would have failed to fetch
# the required-check list even if Gitea had a merge queue.
#
# What this stub preserves:
#
# - Workflow name `Check merge_group trigger on required workflows`
# is unchanged so any external surface that referenced it (none
# confirmed via branch-protection audit 2026-05-07) keeps resolving.
# - Trigger surface (pull_request, push, merge_group). The
# merge_group: trigger is a no-op on Gitea (the event never fires)
# but kept declared so the workflow file itself stays a faithful
# subscriber to anything that DID fire it on the GitHub side, in
# case we ever migrate back.
#
# Re-enabling real linting (future work):
#
# - If/when Gitea grows a merge queue (none on the roadmap as of
# 2026-05-07), reinstate the linter using
# `/api/v1/repos/.../branch_protections/{branch}` to read the
# required-check list (.status_check_contexts), then walk
# workflow files for the appropriate Gitea-shape trigger.
# - Alternative invariant worth checking on Gitea today: that any
# workflow producing a status_check_context declared in branch
# protection has at least one non-skipped path through to a
# terminal step (the "required check name needs a job that always
# runs" invariant from saved memory
# feedback_branch_protection_check_name_parity). That linter
# exists separately as `branch-protection-drift.yml` so this stub
# doesn't need to also cover it.
#
# Until merge-queue semantics return to this fleet, the stub keeps
# the workflow surface visible in Gitea's Actions UI so the next
# operator notices it's a stub instead of a missing surface, and
# emits success so the surrounding chain isn't artificially red.
#
# Issue tracking: post-#66 sweep tracked in #75 (class D — `gh api`
# REST passthroughs that have no Gitea v1 equivalent).
on:
pull_request:
@ -25,7 +70,8 @@ on:
paths:
- '.github/workflows/**.yml'
- '.github/workflows/**.yaml'
# Self-listen on merge_group so the linter passes its own queue run.
# Self-listen on merge_group is structurally a no-op on Gitea; kept
# declared for parity with the GitHub-era contract.
merge_group:
types: [checks_requested]
@ -36,88 +82,25 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify merge_group trigger on required-check workflows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
- name: Stub — Gitea has no merge queue
shell: bash
run: |
set -euo pipefail
cat <<'EOF'
merge_group-trigger linter is a no-op on Gitea Actions.
# Branch we care about — the one merge queue runs on.
BRANCH=staging
Reason: Gitea has no merge queue. The dead-lock pattern this
linter catches (required check missing merge_group: trigger
→ AWAITING_CHECKS forever on gh-readonly-queue refs) cannot
occur on this SCM by construction.
# Pull the list of required status check contexts. If the branch
# has no protection or no required checks, exit clean — nothing
# to lint.
REQUIRED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" \
--jq '.contexts[]' 2>/dev/null || true)
if [ -z "$REQUIRED" ]; then
echo "No required status checks on ${BRANCH} — nothing to verify."
exit 0
fi
Adjacent live linters that DO catch real Gitea-shape failure
modes:
- branch-protection-drift.yml — guards the protection rules
- check-name parity gate (PR #56, issue #144) — guards
that path-filtered workflows still emit required checks
echo "Required checks on ${BRANCH}:"
echo "${REQUIRED}" | sed 's/^/ - /'
echo
# Build a map: workflow file -> set of job names declared in it.
# We use yq if available, otherwise grep the `name:` lines under
# `jobs:`. Stick with grep for portability — runner image always
# has it; yq isn't in the default image as of 2026-04.
declare -A workflow_jobs
shopt -s nullglob
for wf in .github/workflows/*.yml .github/workflows/*.yaml; do
[ -f "$wf" ] || continue
# Extract the workflow name (the `name:` at file root).
wf_name=$(awk '/^name:[[:space:]]/ {sub(/^name:[[:space:]]+/,""); gsub(/^"|"$/,""); print; exit}' "$wf")
# Extract job step names from the `jobs:` block. A job step is:
# - id under `jobs:` (key with 2-space indent followed by colon)
# - the `name:` field inside that job (4-space indent)
# We collect both because required_status_checks contexts can
# match either, depending on how the workflow was authored.
jobs_block=$(awk '/^jobs:/{flag=1; next} flag' "$wf")
job_names=$(echo "$jobs_block" | awk '/^[[:space:]]{4}name:[[:space:]]/ {sub(/^[[:space:]]+name:[[:space:]]+/,""); gsub(/^["'"'"']|["'"'"']$/,""); print}')
workflow_jobs["$wf"]="${wf_name}"$'\n'"${job_names}"
done
# For each required check, find the workflow that produces it.
# Then verify that workflow lists merge_group as a trigger.
FAILED=0
while IFS= read -r check; do
[ -z "$check" ] && continue
owning_wf=""
for wf in "${!workflow_jobs[@]}"; do
if echo "${workflow_jobs[$wf]}" | grep -Fxq "$check"; then
owning_wf="$wf"
break
fi
done
if [ -z "$owning_wf" ]; then
echo "::warning::Required check '${check}' has no matching workflow in this repo. Skipping (may be from an external app)."
continue
fi
# Does the workflow's trigger list include merge_group?
# Match either bare `merge_group:` line or merge_group with
# subsequent indented config (types: [checks_requested]).
if grep -qE '^[[:space:]]*merge_group:' "$owning_wf"; then
echo "OK: '${check}' (in $owning_wf) — has merge_group trigger"
else
echo "::error file=${owning_wf}::Required check '${check}' is produced by ${owning_wf}, but the workflow does not declare a 'merge_group:' trigger. With merge queue enabled on ${BRANCH}, this will deadlock the queue (every PR sits AWAITING_CHECKS forever). Add this to the workflow's 'on:' block:"
echo "::error file=${owning_wf}:: merge_group:"
echo "::error file=${owning_wf}:: types: [checks_requested]"
FAILED=1
fi
done <<< "$REQUIRED"
if [ "$FAILED" -ne 0 ]; then
echo
echo "::error::Block. See errors above. Reference: $(grep -l 'reference_merge_queue' /dev/null 2>/dev/null || echo 'memory: reference_merge_queue_enablement.md')."
exit 1
fi
echo
echo "All required workflows on ${BRANCH} declare merge_group triggers."
Issue: #75 (post-#66 gh-CLI sweep, class D — gh api REST
calls that have no Gitea v1 equivalent).
EOF
echo "::notice::merge_group-trigger linter is a stub on Gitea — see workflow file header for context."

View File

@ -305,42 +305,50 @@ jobs:
# Only fires on direct pushes to main (i.e. after staging→main promotion).
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
# Required to post commit comments via the GitHub API.
contents: write
contents: read
steps:
- name: Post deploy reminder as commit comment
- name: Write deploy reminder to step summary
# Post-2026-05-06 (Gitea migration, issue #75): the previous
# version called `gh api -X POST repos/.../commits/SHA/comments`
# to drop a commit comment for the operator. Gitea's REST API
# does NOT expose `/repos/{owner}/{repo}/commits/{sha}/comments`
# at all (verified 2026-05-07 — only commit *statuses* exist on
# the commit endpoint; comments don't), so the call 404'd on
# every main push since the migration.
#
# The reminder content is entirely operator-facing and just as
# discoverable in the run summary, so write it there instead.
# Operators land on the workflow run page anyway when they need
# to action a deploy; commit comments were a stale UI artefact
# of the GitHub era.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT_SHA: ${{ github.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Write body to a temp file — avoids backtick escaping in shell.
cat > /tmp/deploy-reminder.md << 'BODY'
## Canvas build passed ✅ — deploy required
The `publish-canvas-image` workflow is now building a fresh Docker image
(`ghcr.io/molecule-ai/canvas:latest`) in the background.
Once it completes (~35 min), apply on the host machine with:
```bash
cd <runner-workspace>
git pull origin main
docker compose pull canvas && docker compose up -d canvas
```
If you need to rebuild from local source instead (e.g. testing unreleased
changes or a new `NEXT_PUBLIC_*` URL), use:
```bash
docker compose build canvas && docker compose up -d canvas
```
BODY
printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \
"$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md
gh api \
--method POST \
"repos/${{ github.repository }}/commits/${{ github.sha }}/comments" \
--field "body=@/tmp/deploy-reminder.md"
{
echo "## Canvas build passed — deploy required"
echo
echo "The \`publish-canvas-image\` workflow is now building a fresh Docker image"
echo "(\`ghcr.io/molecule-ai/canvas:latest\`) in the background."
echo
echo "Once it completes (~35 min), apply on the host machine with:"
echo
echo '```bash'
echo "cd <runner-workspace>"
echo "git pull origin main"
echo "docker compose pull canvas && docker compose up -d canvas"
echo '```'
echo
echo "If you need to rebuild from local source instead (e.g. testing unreleased"
echo "changes or a new \`NEXT_PUBLIC_*\` URL), use:"
echo
echo '```bash'
echo "docker compose build canvas && docker compose up -d canvas"
echo '```'
echo
printf '> Posted automatically by CI · commit `%s` · [build log](%s)\n' \
"$COMMIT_SHA" "$RUN_URL"
} >> "$GITHUB_STEP_SUMMARY"
# Python Lint & Test — required check, always runs. See platform-build
# for the rationale.