Skip to content
Merged
Show file tree
Hide file tree
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
87 changes: 87 additions & 0 deletions .github/workflows/capture-to-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Mirrors every submitted .deb into the org-controlled `staging` release the
# moment a publish PR is opened or updated, so pending binaries never depend
# on a contributor's fork (deleted fork/release = lost package otherwise).
#
# Security notes (this runs on pull_request_target, i.e. with write access):
# - The PR's code is NEVER checked out or executed. We only read the
# changed *.deb.release.json manifests through the GitHub API and treat
# them as pure data (parsed with jq).
# - filename / url are attacker-controlled strings: filename must match a
# strict Debian-package pattern (no slashes), url must live on GitHub.
# - The download is verified against the manifest's sha256 before upload.
# The .deb is never executed. Full semantic validation still happens in
# validate-pr.yml, and promotion re-verifies sha256 again.
name: Capture Submission to Staging

on:
pull_request_target:
types: [opened, synchronize]
paths:
- 'pool/main/**/*.deb.release.json'

permissions:
contents: write

concurrency:
group: capture-${{ github.event.pull_request.number }}
cancel-in-progress: false

jobs:
capture:
runs-on: ubuntu-latest
steps:
- name: Mirror manifested .deb into the staging release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail

gh release view staging -R "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \
gh release create staging -R "$GITHUB_REPOSITORY" --prerelease \
--title "Staging pool (pending submissions)" \
--notes "Org-controlled buffer: pending submissions' .deb live here until promoted into apt-pool."

gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate \
--jq '.[] | select(.status != "removed") | .filename' \
| grep -E '^pool/main/[^/]+/[^/]+\.deb\.release\.json$' > /tmp/manifests.txt || true
if [ ! -s /tmp/manifests.txt ]; then
echo "no manifests changed in this PR"; exit 0
fi

mkdir -p /tmp/cap
while read -r path; do
echo "── $path"
gh api "repos/$HEAD_REPO/contents/$path?ref=$HEAD_SHA" --jq '.content' | base64 -d > /tmp/m.json
name=$(jq -r '.filename // empty' /tmp/m.json)
url=$(jq -r '.url // empty' /tmp/m.json)
want=$(jq -r '.sha256 // empty' /tmp/m.json)

# Strict validation — these strings come from an untrusted PR.
echo "$name" | grep -qE '^[a-z0-9][a-zA-Z0-9.+~_-]*_(arm64|all)\.deb$' \
|| { echo "::error::bad filename '$name' in $path"; exit 1; }
case "$url" in
https://github.com/*|https://objects.githubusercontent.com/*) ;;
*) echo "::error::url must be hosted on GitHub ($path)"; exit 1;;
esac
[ -n "$want" ] || { echo "::error::manifest $path missing sha256"; exit 1; }

# Skip when staging already holds this exact content.
rm -rf /tmp/capdl && mkdir -p /tmp/capdl
if gh release download staging -R "$GITHUB_REPOSITORY" --pattern "$name" --dir /tmp/capdl 2>/dev/null \
&& [ -f "/tmp/capdl/$name" ] \
&& [ "$(sha256sum "/tmp/capdl/$name" | awk '{print $1}')" = "$want" ]; then
echo "already staged: $name"
continue
fi

curl -fL --retry 3 --max-time 600 -o "/tmp/cap/$name" "$url"
got=$(sha256sum "/tmp/cap/$name" | awk '{print $1}')
[ "$got" = "$want" ] || { echo "::error::sha256 mismatch for $name (manifest: $want, downloaded: $got)"; exit 1; }

gh release upload staging "/tmp/cap/$name" -R "$GITHUB_REPOSITORY" --clobber
echo "staged: $name ($(du -h "/tmp/cap/$name" | cut -f1))"
rm -f "/tmp/cap/$name"
done < /tmp/manifests.txt
22 changes: 17 additions & 5 deletions .github/workflows/update-index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,29 @@ jobs:
echo "already in pool: $name"
continue
fi
echo "promoting $name from $url"
# The release asset name is the uploaded file's basename, so the temp
# file MUST be named exactly $name (a "promote-$name" temp path would
# upload the asset as "promote-<name>", which the Collect step below
# then fails to find).
mkdir -p /tmp/promote
tmp="/tmp/promote/$name"
curl -fL --retry 3 --max-time 600 -o "$tmp" "$url"
got_sha=$(sha256sum "$tmp" | awk '{print $1}')
if [ -n "$want_sha" ] && [ "$want_sha" != "null" ] && [ "$got_sha" != "$want_sha" ]; then
echo "::error::sha256 mismatch for $name (want $want_sha got $got_sha)"; exit 1
# Prefer the org-controlled staging mirror (populated by
# capture-to-staging.yml when the publish PR was opened); fall back
# to the manifest url (e.g. a contributor fork) if staging misses
# or its content does not match the manifest sha256.
fetched=""
for src in "https://github.com/${GITHUB_REPOSITORY}/releases/download/staging/$name" "$url"; do
rm -f "$tmp"
echo "promoting $name from $src"
curl -fL --retry 3 --max-time 600 -o "$tmp" "$src" || continue
got_sha=$(sha256sum "$tmp" | awk '{print $1}')
if [ -n "$want_sha" ] && [ "$want_sha" != "null" ] && [ "$got_sha" != "$want_sha" ]; then
echo "::warning::sha256 mismatch for $name from $src (want $want_sha got $got_sha)"; continue
fi
fetched=1; break
done
if [ -z "$fetched" ]; then
echo "::error::could not fetch $name with a matching sha256 from staging or $url"; exit 1
fi
gh release upload "$POOL_TAG" "$tmp" --clobber
rm -f "$tmp"
Expand Down