Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,99 @@ jobs:
echo "build_id=$BUILD_ID" >> "$GITHUB_OUTPUT"
echo "built_at=$BUILT_AT" >> "$GITHUB_OUTPUT"

# Prove the release credential BEFORE the matrix runs.
#
# OpenIPC/firmware run 33273333359 built 102 boards over two hours and then
# died in `Publish releases` with "403 Resource not accessible by
# integration". Nothing earlier in that run had any opinion about whether
# it could publish, so the price of the misconfiguration was the whole
# matrix. This repo builds 107 devices and publishes the same way.
#
# A draft release is not visible to anyone and creates no tag, so this
# costs a couple of API calls and turns a long failure into a
# ten-second one. Checking the secret is merely non-empty would not have
# caught that incident at all: the old token existed and was simply
# refused, so the probe has to attempt the write.
#
# Three details keep the probe from becoming a problem of its own:
#
# - The tag carries run_attempt as well as run_id. run_id is stable
# across re-runs, so without the attempt a leftover draft from a
# failed run would collide with its own re-run and report a valid
# credential as broken -- a guard that fails wrongly is worse than no
# guard, because it blocks good runs.
# - Deletion happens from a trap, so an interrupted or cancelled job
# still cleans up rather than leaving the draft behind.
# - A best-effort sweep removes probe drafts stranded by earlier runs,
# because cleanup.yml does not know this tag pattern. It is capped at
# the 10 most recent releases and every failure is swallowed: listing
# 100 releases here returns HTTP 504 (each carries hundreds of
# assets), and a housekeeping step must never be what fails a build.
#
# Skipped on pull_request, which neither publishes nor has the secret.
- name: Prove the release credential can write
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GH_REPO: ${{ github.repository }}
PROBE_TAG: ci-release-credential-probe-${{ github.run_id }}-${{ github.run_attempt }}
run: |
set -euo pipefail

if [ -z "${GH_TOKEN:-}" ]; then
echo "::error::RELEASE_TOKEN is not set. The matrix would build"\
"every device and then fail to publish. See the publish job."
exit 1
fi

probe_id=""
cleanup() {
[ -n "$probe_id" ] || return 0
gh api -X DELETE "repos/${GH_REPO}/releases/${probe_id}" >/dev/null 2>&1 || true
}
trap cleanup EXIT

# Strand-clearing, entirely best effort.
gh api "repos/${GH_REPO}/releases?per_page=10" \
--jq '.[] | select(.draft and (.tag_name | startswith("ci-release-credential-probe-"))) | .id' \
2>/dev/null | while read -r stale; do
# gh writes its error body to STDOUT, so a failed list feeds this
# loop lines of JSON rather than ids. Deleting them is harmless --
# the calls just fail -- but logging "removing stranded probe
# draft {"status": "401"}" is a confident lie in the log of a step
# whose whole job is to be believed. Only act on real ids.
case "$stale" in ''|*[!0-9]*) continue ;; esac
echo "removing stranded probe draft $stale"
gh api -X DELETE "repos/${GH_REPO}/releases/${stale}" >/dev/null 2>&1 || true
done || true

# Retried, because a one-shot POST cannot tell a bad credential from
# a bad minute. GitHub answers 403 for secondary rate limiting as
# well as for refusal -- the publish step below retries six times for
# exactly that reason -- and these repos return 504 on ordinary
# release listings often enough to have been seen while writing this.
# Reporting a blip as "the token is wrong" would send whoever reads
# it to reissue a credential that was never the problem.
#
# Three attempts rather than the publish step's six: the value here is
# failing fast, and 5s + 10s of backoff still absorbs a transient
# while keeping a genuine misconfiguration well under a minute.
attempt=0
until probe_id=$(gh api -X POST "repos/${GH_REPO}/releases" \
-f tag_name="$PROBE_TAG" -f name="$PROBE_TAG" \
-F draft=true --jq .id); do
attempt=$((attempt + 1))
if [ "$attempt" -ge 3 ]; then
echo "::error::RELEASE_TOKEN could not create a release in"\
"${GH_REPO} after 3 attempts. If this is not a transient API"\
"error, the token needs Contents: read and write."
exit 1
fi
echo "attempt ${attempt} failed, retrying in $((attempt * 5))s"
sleep $((attempt * 5))
done
echo "release credential OK"

buildroot:
name: Firmware
needs: [preflight, select]
Expand Down Expand Up @@ -450,7 +543,18 @@ jobs:
- name: Publish releases (paced, retry-aware)
if: steps.collect.outputs.count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Not GITHUB_TOKEN. The default token's release-write depends on
# who triggered the run: a `schedule:` run executes as the repository's
# scheduling identity and could write releases, but a dispatched run
# executes as whoever dispatched it, and for an app/OAuth-backed
# identity the API answers "403 Resource not accessible by
# integration" even though the job declares `contents: write` and the
# run log confirms the token was granted it. OpenIPC/firmware hit
# exactly this on 2026-08-29 (its run 33273333359 built for two hours
# and then could not publish). This repo publishes the same way and
# was next in line. An explicit credential also makes publishing
# independent of who or what starts the run.
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GH_REPO: ${{ github.repository }}
BUILD_ID: ${{ needs.preflight.outputs.build_id }}
HEAD_SHA: ${{ needs.preflight.outputs.head_sha }}
Expand Down
Loading