Build Ports #91
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Ports | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| port: | |
| description: "Which port to consider (all = every port in registry)" | |
| type: string | |
| required: false | |
| default: "all" | |
| force-build: | |
| description: "Force build (bypass SHA check, upload artifact only, no commit)" | |
| type: boolean | |
| required: false | |
| default: false | |
| schedule: | |
| - cron: '30 4 * * *' | |
| permissions: | |
| contents: write | |
| issues: write | |
| packages: write | |
| concurrency: | |
| group: build-ports | |
| cancel-in-progress: false | |
| jobs: | |
| # ──────────────────────────────────────────────────────────────────── | |
| # Discover which ports need building | |
| # | |
| # Emits a JSON array suitable for `strategy.matrix.include`. Each entry | |
| # carries just the minimal handoff the matrix job needs (id, upstream_sha, | |
| # label); everything else (port_dir, target_dirs, artifacts, prefix) is | |
| # re-read from registry.json inside the matrix job to keep the matrix | |
| # payload small. | |
| # ──────────────────────────────────────────────────────────────────── | |
| discover: | |
| runs-on: ubuntu-24.04-arm | |
| outputs: | |
| matrix: ${{ steps.compute.outputs.matrix }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Compute matrix | |
| id: compute | |
| run: | | |
| set -euo pipefail | |
| SELECTED="${{ inputs.port }}" | |
| FORCE="${{ inputs.force-build }}" | |
| : "${SELECTED:=all}" | |
| : "${FORCE:=false}" | |
| if [[ "$SELECTED" == "all" ]]; then | |
| ids=$(jq -r '.ports | keys[]' buildtools/registry.json) | |
| else | |
| if ! jq -e --arg id "$SELECTED" '.ports[$id]' buildtools/registry.json > /dev/null; then | |
| echo "Port '$SELECTED' not found in buildtools/registry.json"; exit 1 | |
| fi | |
| ids="$SELECTED" | |
| fi | |
| matrix_entries=() | |
| while read -r id; do | |
| [[ -z "$id" ]] && continue | |
| repo=$(jq -r --arg id "$id" '.ports[$id].upstream_repo' buildtools/registry.json) | |
| # Two tracking modes: | |
| # branch (default): resolve HEAD of upstream_branch to a SHA. | |
| # Right for ports without release tags (e.g. | |
| # Sonic Mania decomp which has rolling main). | |
| # release: resolve the latest published release tag to | |
| # its commit SHA. Right for projects with a | |
| # stable release cadence (e.g. sonic3air). | |
| track_mode=$(jq -r --arg id "$id" '.ports[$id].track // "branch"' buildtools/registry.json) | |
| # Scheduled runs only consider release-tagged ports. Branch-tracked | |
| # upstreams (rolling main, no release cadence — e.g. rsdkv3 nightlies) | |
| # would rebuild on every cron tick; manual workflow_dispatch is the | |
| # right trigger for those. | |
| if [[ "$GITHUB_EVENT_NAME" == "schedule" && "$track_mode" != "release" ]]; then | |
| echo "[$id] skipping in scheduled run (track=$track_mode; cron is release-only)" | |
| continue | |
| fi | |
| # Skip sm64-ghostship on scheduled (cron) builds for now | |
| if [[ "$GITHUB_EVENT_NAME" == "schedule" && "$id" == "sm64-ghostship" ]]; then | |
| echo "[$id] TEMP skip in scheduled run (pinned to manual develop build; see workflow comment)" | |
| continue | |
| fi | |
| # A port can fan out to multiple target dirs (e.g. one build | |
| # produces both sonic.1 and sonic.2 payloads). Use the first as | |
| # the SHA marker source of truth; all targets stay in sync. | |
| first_target=$(jq -r --arg id "$id" '.ports[$id].target_dirs[0]' buildtools/registry.json) | |
| case "$track_mode" in | |
| release) | |
| tag=$(curl -sf "https://api.github.com/repos/$repo/releases/latest" | jq -r .tag_name) || tag="" | |
| if [[ -z "$tag" || "$tag" == "null" ]]; then | |
| echo "[$id] failed to resolve latest release on $repo — skipping" | |
| continue | |
| fi | |
| # Resolve the tag to the commit SHA it points at. Annotated | |
| # tags require one extra hop via the tag object. | |
| ref=$(curl -sf "https://api.github.com/repos/$repo/git/ref/tags/$tag") || ref="" | |
| if [[ -z "$ref" ]]; then | |
| echo "[$id] failed to resolve tag ref on $repo — skipping" | |
| continue | |
| fi | |
| ref_type=$(echo "$ref" | jq -r .object.type) | |
| if [[ "$ref_type" == "tag" ]]; then | |
| tag_obj_url=$(echo "$ref" | jq -r .object.url) | |
| upstream_sha=$(curl -sf "$tag_obj_url" | jq -r .object.sha) || upstream_sha="" | |
| else | |
| upstream_sha=$(echo "$ref" | jq -r .object.sha) | |
| fi | |
| source="$repo@$tag" | |
| # Human label for the commit message — tag name conveys | |
| # release version more clearly than a 7-char SHA. | |
| label="$tag" | |
| ;; | |
| branch|*) | |
| branch=$(jq -r --arg id "$id" '.ports[$id].upstream_branch' buildtools/registry.json) | |
| upstream_sha=$(curl -sf "https://api.github.com/repos/$repo/commits/$branch" | jq -r .sha) || upstream_sha="" | |
| source="$repo@$branch" | |
| label="${upstream_sha:0:7}" | |
| ;; | |
| esac | |
| if [[ -z "$upstream_sha" || "$upstream_sha" == "null" ]]; then | |
| echo "[$id] failed to resolve upstream SHA on $source — skipping" | |
| continue | |
| fi | |
| marker="$first_target/.upstream-sha" | |
| if [[ -f "$marker" ]]; then | |
| origin_sha=$(cat "$marker") | |
| else | |
| origin_sha="" | |
| fi | |
| if [[ "$FORCE" == "true" ]] || [[ "$upstream_sha" != "$origin_sha" ]]; then | |
| echo "[$id] needs build (source=$source origin=${origin_sha:-none} upstream=$upstream_sha)" | |
| base=$(jq -r --arg id "$id" '.ports[$id].base // "rhh-base"' buildtools/registry.json) | |
| matrix_entries+=("$(jq -cn --arg id "$id" --arg sha "$upstream_sha" --arg label "$label" --arg base "$base" \ | |
| '{id:$id, upstream_sha:$sha, label:$label, base:$base}')") | |
| else | |
| echo "[$id] up to date at $upstream_sha — skipping" | |
| fi | |
| done <<< "$ids" | |
| # Build the JSON array. Empty list emits `[]` so the matrix job's | |
| # `if:` guard skips it cleanly (an empty matrix is a GH Actions | |
| # config error). | |
| if (( ${#matrix_entries[@]} == 0 )); then | |
| matrix="[]" | |
| else | |
| matrix=$(printf '%s\n' "${matrix_entries[@]}" | jq -cs .) | |
| fi | |
| echo "matrix=$matrix" >> "$GITHUB_OUTPUT" | |
| echo "Matrix: $matrix" | |
| # ──────────────────────────────────────────────────────────────────── | |
| # Build the shared rhh-base image once, push to GHCR | |
| # | |
| # Matrix jobs pull it instead of rebuilding it locally — a from-scratch | |
| # rhh-base build takes ~5 minutes and would be paid N times under matrix. | |
| # Tag = sha1 of Dockerfile.base so we only actually build + push when the | |
| # base recipe changes; otherwise we just confirm the existing tag is | |
| # reachable. | |
| # ──────────────────────────────────────────────────────────────────── | |
| base-image: | |
| runs-on: ubuntu-24.04-arm | |
| needs: discover | |
| if: needs.discover.outputs.matrix != '[]' | |
| outputs: | |
| image: ${{ steps.tag.outputs.image }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Compute image tag | |
| id: tag | |
| run: | | |
| set -euo pipefail | |
| digest=$(sha1sum buildtools/Dockerfile.base | cut -c1-12) | |
| # Lowercase the owner (GHCR rejects uppercase in image names; the | |
| # repo owner might be capitalised, e.g. JeodC). | |
| owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') | |
| image="ghcr.io/$owner/rhh-base:$digest" | |
| echo "image=$image" >> "$GITHUB_OUTPUT" | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push if not cached | |
| run: | | |
| set -euo pipefail | |
| image="${{ steps.tag.outputs.image }}" | |
| if docker pull "$image" >/dev/null 2>&1; then | |
| echo "Base image $image already in registry; skipping build." | |
| exit 0 | |
| fi | |
| echo "Building $image from Dockerfile.base..." | |
| docker build --platform linux/aarch64 -t "$image" -f buildtools/Dockerfile.base buildtools/ | |
| docker push "$image" | |
| # ──────────────────────────────────────────────────────────────────── | |
| # Build each port in parallel | |
| # | |
| # - fail-fast: false so one port's build failure doesn't cancel the others. | |
| # - continue-on-error catches port-level build failures so the job still | |
| # exits 0; per-port success/failure is recorded in a status artifact for | |
| # the issues job to consume. This is what lets the overall workflow | |
| # conclusion stay 'success' on partial-success runs, so the workflow_run | |
| # chain into Release Ports fires reliably. | |
| # - max-parallel caps concurrent runner-minute burn | |
| # ──────────────────────────────────────────────────────────────────── | |
| build: | |
| runs-on: ubuntu-24.04-arm | |
| needs: [discover, base-image] | |
| if: needs.discover.outputs.matrix != '[]' | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 3 | |
| matrix: | |
| port: ${{ fromJSON(needs.discover.outputs.matrix) }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Pull rhh-base from GHCR | |
| if: matrix.port.base == 'rhh-base' | |
| run: | | |
| set -euo pipefail | |
| docker pull "${{ needs.base-image.outputs.image }}" | |
| docker tag "${{ needs.base-image.outputs.image }}" rhh-base | |
| - name: Build port | |
| id: build | |
| continue-on-error: ${{ inputs.port == '' || inputs.port == 'all' }} | |
| run: | | |
| set -uo pipefail | |
| FORCE="${{ inputs.force-build }}" | |
| : "${FORCE:=false}" | |
| id='${{ matrix.port.id }}' | |
| port_dir=$(jq -r --arg id "$id" '.ports[$id].port_dir' buildtools/registry.json) | |
| # Capture build output to a file as well as the runner log so the | |
| # issues job can attach a focused tail without needing to download | |
| # and grep the full job log via API. | |
| mkdir -p _ci/status | |
| if FORCE_HEAD="$FORCE" bash buildtools/build_port.sh "$port_dir" 2>&1 | tee "_ci/status/build.log"; then | |
| : # pipefail above propagates build_port.sh's exit code | |
| fi | |
| rc=${PIPESTATUS[0]} | |
| echo "rc=$rc" >> "$GITHUB_OUTPUT" | |
| if (( rc != 0 )); then | |
| echo "::error::[$id] build_port.sh failed (rc=$rc)" | |
| # Exit non-zero so the step records a failure. For batch runs the | |
| # continue-on-error expression above masks it (job stays green, | |
| # other ports proceed); for a single-port dispatch it surfaces as | |
| # a failed run. | |
| exit "$rc" | |
| fi | |
| - name: Validate artifacts and sync to target_dirs | |
| id: sync | |
| if: steps.build.outputs.rc == '0' && inputs.force-build != true | |
| run: | | |
| set -euo pipefail | |
| id='${{ matrix.port.id }}' | |
| port_dir=$(jq -r --arg id "$id" '.ports[$id].port_dir' buildtools/registry.json) | |
| mapfile -t target_dirs < <(jq -r --arg id "$id" '.ports[$id].target_dirs[]' buildtools/registry.json) | |
| commit_prefix=$(jq -r --arg id "$id" '.ports[$id].commit_prefix' buildtools/registry.json) | |
| artifacts=$(jq -r --arg id "$id" '.ports[$id].artifacts[]' buildtools/registry.json) | |
| upstream_sha='${{ matrix.port.upstream_sha }}' | |
| label='${{ matrix.port.label }}' | |
| # Verify every required artifact was produced. If any is missing, | |
| # the build silently failed (e.g. docker exec exit code swallowed | |
| # by build_port.sh, or a step in build.txt didn't fail-fast). | |
| # Bailing here prevents committing a stale .upstream-sha that would | |
| # make the next run think we're up-to-date when no binaries changed. | |
| # | |
| # Artifact entries containing '*' are treated as globs (useful for | |
| # multi-volume archives like data.7z.* where the part count can | |
| # vary between upstream versions). | |
| missing=() | |
| shopt -s nullglob | |
| while read -r a; do | |
| [[ -z "$a" ]] && continue | |
| if [[ "$a" == *\** ]]; then | |
| matches=($port_dir/$a) | |
| (( ${#matches[@]} == 0 )) && missing+=("$a") | |
| elif [[ ! -e "$port_dir/$a" ]]; then | |
| missing+=("$a") | |
| fi | |
| done <<< "$artifacts" | |
| shopt -u nullglob | |
| if (( ${#missing[@]} > 0 )); then | |
| echo "::error::[$id] build produced no output — missing artifacts: ${missing[*]}" | |
| echo "validated=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Sync to target_dirs (handles globs, dir-vs-file, prefix paths). | |
| shopt -s nullglob | |
| for target_dir in "${target_dirs[@]}"; do | |
| mkdir -p "$target_dir" | |
| while read -r a; do | |
| [[ -z "$a" ]] && continue | |
| if [[ "$a" == *\** ]]; then | |
| # Glob artifact: purge any existing matches in target_dir | |
| # first so shrinking part counts (e.g. 3 parts → 2 parts) | |
| # don't leave orphaned files behind. | |
| for stale in $target_dir/$a; do rm -rf "$stale"; done | |
| for f in $port_dir/$a; do | |
| cp -r "$f" "$target_dir/$(basename "$f")" | |
| done | |
| else | |
| # Path artifacts may include a subdirectory prefix (e.g. | |
| # `doc/license.txt`), so ensure the parent dir exists. | |
| mkdir -p "$target_dir/$(dirname "$a")" | |
| if [[ -d "$port_dir/$a" ]]; then | |
| rm -rf "$target_dir/$a" | |
| cp -r "$port_dir/$a" "$target_dir/$a" | |
| elif [[ -f "$port_dir/$a" ]]; then | |
| cp "$port_dir/$a" "$target_dir/$a" | |
| fi | |
| fi | |
| done <<< "$artifacts" | |
| echo "$upstream_sha" > "$target_dir/.upstream-sha" | |
| done | |
| shopt -u nullglob | |
| # Package the synced trees + commit metadata for the commit job. | |
| # tar preserves relative paths so the commit job can extract over | |
| # its own workspace at the same locations. | |
| mkdir -p _ci/payload | |
| tar czf "_ci/payload/payload.tar.gz" "${target_dirs[@]}" | |
| jq -cn \ | |
| --arg id "$id" \ | |
| --arg prefix "$commit_prefix" \ | |
| --arg label "$label" \ | |
| --arg sha "$upstream_sha" \ | |
| --argjson targets "$(printf '%s\n' "${target_dirs[@]}" | jq -R . | jq -s .)" \ | |
| '{id:$id, commit_prefix:$prefix, label:$label, upstream_sha:$sha, target_dirs:$targets}' \ | |
| > "_ci/payload/meta.json" | |
| echo "validated=true" >> "$GITHUB_OUTPUT" | |
| - name: Stage force-build output | |
| if: steps.build.outputs.rc == '0' && inputs.force-build == true | |
| run: | | |
| set -euo pipefail | |
| id='${{ matrix.port.id }}' | |
| port_dir=$(jq -r --arg id "$id" '.ports[$id].port_dir' buildtools/registry.json) | |
| artifacts=$(jq -r --arg id "$id" '.ports[$id].artifacts[]' buildtools/registry.json) | |
| short='${{ matrix.port.upstream_sha }}' | |
| short="${short:0:7}" | |
| mkdir -p "_ci/force/$id-$short" | |
| shopt -s nullglob | |
| while read -r a; do | |
| [[ -z "$a" ]] && continue | |
| if [[ "$a" == *\** ]]; then | |
| for f in $port_dir/$a; do | |
| cp -r "$f" "_ci/force/$id-$short/" | |
| done | |
| elif [[ -e "$port_dir/$a" ]]; then | |
| cp -r "$port_dir/$a" "_ci/force/$id-$short/" | |
| fi | |
| done <<< "$artifacts" | |
| shopt -u nullglob | |
| - name: Upload commit payload | |
| if: steps.sync.outputs.validated == 'true' && inputs.force-build != true | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: payload-${{ matrix.port.id }} | |
| path: _ci/payload/ | |
| retention-days: 1 | |
| if-no-files-found: error | |
| - name: Upload force-build output | |
| if: steps.build.outputs.rc == '0' && inputs.force-build == true | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: force-payload-${{ matrix.port.id }} | |
| path: _ci/force/ | |
| retention-days: 14 | |
| if-no-files-found: ignore | |
| - name: Record port status | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| mkdir -p _ci/status | |
| rc='${{ steps.build.outputs.rc }}' | |
| validated='${{ steps.sync.outputs.validated }}' | |
| if [[ "$rc" == "0" && ( "$validated" == "true" || "${{ inputs.force-build }}" == "true" ) ]]; then | |
| status="success" | |
| else | |
| status="failure" | |
| fi | |
| jq -cn \ | |
| --arg id '${{ matrix.port.id }}' \ | |
| --arg status "$status" \ | |
| --arg label '${{ matrix.port.label }}' \ | |
| '{id:$id, status:$status, label:$label}' \ | |
| > _ci/status/status.json | |
| # Trim the build log to last 200 lines so the artifact stays small; | |
| # the issues job picks the final 40 from this. | |
| [[ -f _ci/status/build.log ]] || touch _ci/status/build.log | |
| tail -200 _ci/status/build.log > _ci/status/build.log.tail | |
| mv _ci/status/build.log.tail _ci/status/build.log | |
| - name: Upload port status | |
| if: always() | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: status-${{ matrix.port.id }} | |
| path: _ci/status/ | |
| retention-days: 7 | |
| if-no-files-found: ignore | |
| # ──────────────────────────────────────────────────────────────────── | |
| # Commit and push all built payloads atomically | |
| # | |
| # Each successful matrix job uploaded its target_dir trees + commit metadata | |
| # as a `payload-<id>` artifact. We download all of them, make one commit | |
| # per port (so per-port `last_commit` resolution in collect_ports.yml keeps | |
| # working), then do a single `git push` so main observes all-or-nothing | |
| # for this run. | |
| # ──────────────────────────────────────────────────────────────────── | |
| commit: | |
| runs-on: ubuntu-24.04-arm | |
| needs: [discover, build] | |
| if: | | |
| always() && | |
| inputs.force-build != true && | |
| needs.discover.result == 'success' && | |
| needs.discover.outputs.matrix != '[]' | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Download all payloads | |
| id: download | |
| uses: actions/download-artifact@v7 | |
| with: | |
| pattern: payload-* | |
| path: _ci/payloads/ | |
| continue-on-error: true | |
| - name: Commit and push | |
| run: | | |
| set -euo pipefail | |
| mapfile -t metas < <(find _ci/payloads -type f -name meta.json | sort) | |
| if (( ${#metas[@]} == 0 )); then | |
| echo "No payloads produced — nothing to commit." | |
| exit 0 | |
| fi | |
| git config user.name "github-actions" | |
| git config user.email "github-actions@github.com" | |
| committed=false | |
| for meta in "${metas[@]}"; do | |
| payload="$(dirname "$meta")" | |
| tarball="$payload/payload.tar.gz" | |
| [[ -f "$meta" && -f "$tarball" ]] || { | |
| echo "::warning::malformed payload at $payload — skipping" | |
| continue | |
| } | |
| id=$(jq -r .id "$meta") | |
| prefix=$(jq -r .commit_prefix "$meta") | |
| label=$(jq -r .label "$meta") | |
| mapfile -t target_dirs < <(jq -r '.target_dirs[]' "$meta") | |
| # Extract the payload tree over the workspace at its original | |
| # relative paths. | |
| tar xzf "$tarball" | |
| for target_dir in "${target_dirs[@]}"; do | |
| git add "$target_dir" | |
| done | |
| if git diff --staged --quiet; then | |
| echo "[$id] no payload changes after extract — skipping commit" | |
| continue | |
| fi | |
| git commit -m "[$prefix] Update to $label" | |
| committed=true | |
| done | |
| if ! $committed; then | |
| echo "No commits produced." | |
| exit 0 | |
| fi | |
| # One push for all N commits. Rebase loop in case collect_ports | |
| # (or anything else) landed a commit between the matrix jobs' | |
| # checkout and now. | |
| for attempt in 1 2 3 4 5; do | |
| git fetch origin "$GITHUB_REF_NAME" | |
| if ! git rebase "origin/$GITHUB_REF_NAME"; then | |
| echo "::error::rebase failed on attempt $attempt" | |
| git rebase --abort || true | |
| exit 1 | |
| fi | |
| if git push origin "HEAD:$GITHUB_REF_NAME"; then | |
| echo "Pushed on attempt $attempt" | |
| exit 0 | |
| fi | |
| echo "Push rejected on attempt $attempt; retrying..." | |
| sleep $((attempt * 3)) | |
| done | |
| echo "::error::push failed after 5 attempts" | |
| exit 1 | |
| # ──────────────────────────────────────────────────────────────────── | |
| # Open or update per-port failure issues | |
| # | |
| # Runs only on scheduled failures (manual runs are interactive and the | |
| # operator already sees what failed). Reads each matrix job's status | |
| # artifact instead of slicing the parent job log — each artifact already | |
| # carries the last 200 lines of its own build, so no log-format parsing | |
| # gymnastics needed. | |
| # ──────────────────────────────────────────────────────────────────── | |
| failure-issues: | |
| runs-on: ubuntu-latest | |
| needs: [discover, base-image, build, commit] | |
| if: always() && github.event_name == 'schedule' | |
| permissions: | |
| issues: write | |
| steps: | |
| - name: Download all statuses | |
| uses: actions/download-artifact@v7 | |
| with: | |
| pattern: status-* | |
| path: _ci/statuses/ | |
| continue-on-error: true | |
| - name: Open or update issues for failures | |
| uses: actions/github-script@v8 | |
| env: | |
| DISCOVER_RESULT: ${{ needs.discover.result }} | |
| BASE_IMAGE_RESULT: ${{ needs.base-image.result }} | |
| COMMIT_RESULT: ${{ needs.commit.result }} | |
| MATRIX: ${{ needs.discover.outputs.matrix }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const TAIL_LINES = 40; | |
| const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| async function openOrComment(titlePrefix, body) { | |
| const { data: openIssues } = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| labels: 'auto-build-failure', | |
| per_page: 100, | |
| }); | |
| const existing = openIssues.find(i => i.title.startsWith(titlePrefix)); | |
| if (existing) { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: existing.number, | |
| body, | |
| }); | |
| core.info(`Commented on existing issue #${existing.number}`); | |
| } else { | |
| const { data: created } = await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: titlePrefix, | |
| body, | |
| labels: ['auto-build-failure'], | |
| }); | |
| core.info(`Created new issue #${created.number}`); | |
| } | |
| } | |
| // Infrastructure failures: discover or base-image broke before | |
| // matrix could run, or the commit job failed after matrix. | |
| // File a single generic issue so the breakage gets noticed. | |
| const infraFailures = []; | |
| if (process.env.DISCOVER_RESULT !== 'success') { | |
| infraFailures.push(`discover (${process.env.DISCOVER_RESULT})`); | |
| } | |
| if (['failure', 'cancelled'].includes(process.env.BASE_IMAGE_RESULT)) { | |
| infraFailures.push(`base-image (${process.env.BASE_IMAGE_RESULT})`); | |
| } | |
| if (process.env.COMMIT_RESULT === 'failure') { | |
| infraFailures.push(`commit (${process.env.COMMIT_RESULT})`); | |
| } | |
| if (infraFailures.length > 0) { | |
| const body = [ | |
| `Scheduled Build Ports run failed at the infrastructure level: ${infraFailures.join(', ')}.`, | |
| ``, | |
| `- Workflow run: ${runUrl}`, | |
| ].join('\n'); | |
| await openOrComment('[auto] Build Ports scheduled run failure', body); | |
| // Fall through — port-level failures (if any) still get their | |
| // own issues below. | |
| } | |
| // Empty matrix is the "nothing to build" steady state — don't | |
| // file anything. | |
| if (process.env.MATRIX === '[]') { | |
| core.info('Matrix was empty; no port-level failures possible.'); | |
| return; | |
| } | |
| // Port-level failures from status artifacts. Walk recursively | |
| // for status.json files: when download-artifact@v7 matches | |
| // exactly one artifact via `pattern`, it extracts contents | |
| // directly into the root path with no per-artifact subdir | |
| // (artifacts.length === 1 special case). Recursive find handles | |
| // both that flat case and the normal nested case. | |
| const root = '_ci/statuses'; | |
| function findStatusFiles(dir) { | |
| let entries; | |
| try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return []; } | |
| const out = []; | |
| for (const e of entries) { | |
| const full = path.join(dir, e.name); | |
| if (e.isDirectory()) out.push(...findStatusFiles(full)); | |
| else if (e.isFile() && e.name === 'status.json') out.push(full); | |
| } | |
| return out; | |
| } | |
| const failed = []; | |
| for (const statusPath of findStatusFiles(root)) { | |
| const s = JSON.parse(fs.readFileSync(statusPath, 'utf8')); | |
| if (s.status !== 'failure') continue; | |
| const logPath = path.join(path.dirname(statusPath), 'build.log'); | |
| let tail = '(log unavailable)'; | |
| if (fs.existsSync(logPath)) { | |
| const lines = fs.readFileSync(logPath, 'utf8').split(/\r?\n/); | |
| while (lines.length && lines[lines.length - 1] === '') lines.pop(); | |
| tail = lines.slice(-TAIL_LINES).join('\n') || '(log empty)'; | |
| } | |
| failed.push({ id: s.id, label: s.label, tail }); | |
| } | |
| if (failed.length === 0) { | |
| core.info('No port-level failures.'); | |
| return; | |
| } | |
| for (const f of failed) { | |
| const body = [ | |
| `Scheduled build of **${f.id}** failed (target: ${f.label}).`, | |
| ``, | |
| `- Workflow run: ${runUrl}`, | |
| ``, | |
| `Last ${TAIL_LINES} lines of this port's build log:`, | |
| '```', | |
| f.tail, | |
| '```', | |
| ].join('\n'); | |
| await openOrComment(`[auto] ${f.id} build failure`, body); | |
| } | |
| # ──────────────────────────────────────────────────────────────────── | |
| # Bundle per-port force-build artifacts into one zip | |
| # | |
| # On force-build, each matrix job uploaded its raw outputs as | |
| # force-payload-<id>. Rebundle them all into a single force-build-outputs | |
| # artifact so the operator only has to download one zip. | |
| # ──────────────────────────────────────────────────────────────────── | |
| force-build-collect: | |
| runs-on: ubuntu-latest | |
| needs: [discover, build] | |
| if: | | |
| always() && | |
| inputs.force-build == true && | |
| needs.discover.result == 'success' && | |
| needs.discover.outputs.matrix != '[]' | |
| steps: | |
| - name: Download all force payloads | |
| uses: actions/download-artifact@v7 | |
| with: | |
| pattern: force-payload-* | |
| path: _ci/force/ | |
| continue-on-error: true | |
| - name: Flatten into single bundle | |
| run: | | |
| set -euo pipefail | |
| mkdir -p _ci/bundle | |
| shopt -s nullglob | |
| # Multi-artifact layout: _ci/force/force-payload-<id>/<id>-<sha>/ | |
| for d in _ci/force/force-payload-*/*/; do | |
| cp -r "$d" _ci/bundle/ | |
| done | |
| for d in _ci/force/*/; do | |
| [[ "$(basename "$d")" == force-payload-* ]] && continue | |
| cp -r "$d" _ci/bundle/ | |
| done | |
| - uses: actions/upload-artifact@v6 | |
| with: | |
| name: force-build-outputs | |
| path: _ci/bundle/ | |
| retention-days: 14 | |
| if-no-files-found: ignore |