From b16453231166dee379cc8314b21fac12e608fcb0 Mon Sep 17 00:00:00 2001 From: baladithyab Date: Wed, 27 May 2026 20:40:32 -0700 Subject: [PATCH 1/2] feat: add OIDC mint step + prefer OIDC over static bearer The Mint OIDC token step calls the runner-local token broker ($ACTIONS_ID_TOKEN_REQUEST_URL) and exchanges the broker token for a short-lived ID token scoped to the configured audience. Failures are non-fatal: if OIDC env vars aren't present (e.g. caller forgot 'permissions: id-token: write'), we fall back to the static PROJECT_EMBED_UPLOAD_TOKEN bearer. The Upload step now picks BEARER from OIDC if available, else static bearer, else fails with explicit guidance. Caller workflows must include in their permissions block: permissions: contents: write pull-requests: write id-token: write Static bearer remains supported for repos that don't (or can't) use OIDC, e.g. manual upload scripts. --- .github/workflows/static-passthrough.yml | 84 +++++++++++++++++++++--- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/.github/workflows/static-passthrough.yml b/.github/workflows/static-passthrough.yml index 9bd5a57..0ee9a8e 100644 --- a/.github/workflows/static-passthrough.yml +++ b/.github/workflows/static-passthrough.yml @@ -1,17 +1,23 @@ name: static-passthrough -# Reusable workflow for embed.kind = "static-html". +# Reusable workflow for embed.kind = "static-html" and friends. # # Uploads a directory tree of static files (HTML/JS/CSS/assets/...) into the # project-embed assets bucket via the personal-site Worker's binding-proxy -# upload endpoint. Auth is a single shared bearer token; no R2 S3 keys -# anywhere. +# upload endpoint. +# +# Auth: GitHub Actions OIDC (primary). The job mints a short-lived ID token +# scoped to a fixed audience and presents it as the bearer; the Worker +# verifies the JWT signature against GitHub's JWKS and authorizes on +# `repository_owner`. No per-repo secrets needed. +# +# Fallback: if `secrets.PROJECT_EMBED_UPLOAD_TOKEN` is set on the calling +# repo, the workflow uses it instead. This keeps existing repos working +# while we roll OIDC out, and is also the right fit for any non-Actions +# uploader (manual scripts, etc.). # # Stored at: r2://assets-r2/// # Served from: https://assets-r2.codeseys.io/// -# -# Calling repos invoke this with `secrets: inherit` so the workflow can -# read the user-level secret PROJECT_EMBED_UPLOAD_TOKEN. on: workflow_call: @@ -36,10 +42,17 @@ on: required: false default: 'https://assets-r2.codeseys.io' type: string + oidc-audience: + description: 'Audience the OIDC token will be minted for (must match the Worker)' + required: false + default: 'https://codeseys.io' + type: string permissions: contents: write pull-requests: write + # Mint OIDC tokens. Required for the OIDC auth path. + id-token: write jobs: build-and-upload: @@ -121,19 +134,72 @@ jobs: echo "bytes=$BYTES" >> $GITHUB_OUTPUT echo "Staged $BYTES bytes ($FILES files) from ${{ inputs.source-dir }}" + - name: Mint OIDC token (primary auth path) + id: oidc + env: + AUDIENCE: ${{ inputs.oidc-audience }} + run: | + set -euo pipefail + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "::warning::OIDC env vars not present; skipping OIDC mint (falling back to static bearer)" + echo "have_oidc=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Mint a JWT scoped to AUDIENCE. Endpoint is the runner-provided + # token broker; the bearer is a one-time exchange token. + # See: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect + TOKEN_RESP=$(curl -sS \ + -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + -H "Accept: application/json" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}") + ID_TOKEN=$(echo "$TOKEN_RESP" | jq -r '.value // empty') + if [ -z "$ID_TOKEN" ] || [ "$ID_TOKEN" = "null" ]; then + echo "::error::OIDC mint returned empty token. Response: $TOKEN_RESP" + exit 1 + fi + # Don't echo the token; just confirm shape (3 segments) and length. + SEGS=$(echo "$ID_TOKEN" | tr '.' '\n' | wc -l) + LEN=${#ID_TOKEN} + if [ "$SEGS" -ne 3 ] || [ "$LEN" -lt 100 ]; then + echo "::error::OIDC token shape wrong (segments=$SEGS, length=$LEN)" + exit 1 + fi + echo "have_oidc=true" >> "$GITHUB_OUTPUT" + # Pass token to subsequent steps via env (masked from logs by convention). + echo "::add-mask::$ID_TOKEN" + # Write to a step env file so the next step picks it up. + { + echo "OIDC_ID_TOKEN<<__OIDC_EOF__" + echo "$ID_TOKEN" + echo "__OIDC_EOF__" + } >> "$GITHUB_ENV" + echo "OIDC token minted: ${SEGS} segments, ${LEN} bytes" + - name: Upload via Worker binding-proxy env: UPLOAD_ENDPOINT: ${{ inputs.upload-endpoint }} UPLOAD_TOKEN: ${{ secrets.PROJECT_EMBED_UPLOAD_TOKEN }} + HAVE_OIDC: ${{ steps.oidc.outputs.have_oidc }} SLUG: ${{ steps.manifest.outputs.slug }} VERSION: ${{ steps.manifest.outputs.short_sha }} STAGE: ${{ steps.stage.outputs.stage_dir }} run: | set -euo pipefail - if [ -z "$UPLOAD_TOKEN" ]; then - echo "::error::PROJECT_EMBED_UPLOAD_TOKEN is not set; configure it as a user/org/repo secret" + # Pick the auth bearer: OIDC (if minted) wins; static fallback otherwise. + if [ "${HAVE_OIDC:-false}" = "true" ] && [ -n "${OIDC_ID_TOKEN:-}" ]; then + BEARER="$OIDC_ID_TOKEN" + AUTH_MODE="oidc" + elif [ -n "${UPLOAD_TOKEN:-}" ]; then + BEARER="$UPLOAD_TOKEN" + AUTH_MODE="bearer" + else + echo "::error::No auth available — neither OIDC nor PROJECT_EMBED_UPLOAD_TOKEN." + echo "::error::For OIDC: caller workflow must include 'permissions: id-token: write'." + echo "::error::For static fallback: set PROJECT_EMBED_UPLOAD_TOKEN as a repo secret." exit 1 fi + echo "Authenticating via: ${AUTH_MODE}" + # Pre-flight health check health=$(curl -s -o /dev/null -w '%{http_code}' "$UPLOAD_ENDPOINT") if [ "$health" != "200" ]; then @@ -151,7 +217,7 @@ jobs: enc_rel=$(jq -nr --arg p "$rel" '$p|@uri') url="${UPLOAD_ENDPOINT}?slug=${SLUG}&version=${VERSION}&path=${enc_rel}" code=$(curl -sS -X PUT "$url" \ - -H "Authorization: Bearer ${UPLOAD_TOKEN}" \ + -H "Authorization: Bearer ${BEARER}" \ --data-binary "@${f}" \ -o /tmp/upload-resp.json \ -w '%{http_code}') From 77ac488e4d2a50871f57238b95c71eae17d88120 Mon Sep 17 00:00:00 2001 From: baladithyab Date: Wed, 27 May 2026 21:32:00 -0700 Subject: [PATCH 2/2] feat: add optional build.script + build.aptPackages support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new steps insert between manifest read and source staging: 1. **Install apt packages** — reads `build.aptPackages` from the manifest and runs `apt-get install -y`. Lets manifests declare non-default tools (ffmpeg, emscripten, cmake, etc.) without requiring caller workflows to pre-install them. 2. **Run build.script** — if the manifest declares `build.script`, chmod +x and execute it from the repo root. The script gets `EMBED_SLUG`, `EMBED_VERSION`, `EMBED_SOURCE_DIR` env vars. It's expected to write its outputs into `source-dir` so the subsequent stage step picks them up. Use cases: - Optimize textures: `ffmpeg` re-encode of large MP4s before upload - Compile WASM: `emcc src/foo.cpp -o assets/foo.html` for C++ → web - Convert notebooks: `jupyter nbconvert --to html` - TypeScript builds, Cargo wasm-pack, etc. Both steps are no-ops when the manifest fields are absent, so existing repos stay back-compat. --- .github/workflows/static-passthrough.yml | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.github/workflows/static-passthrough.yml b/.github/workflows/static-passthrough.yml index 0ee9a8e..1989e76 100644 --- a/.github/workflows/static-passthrough.yml +++ b/.github/workflows/static-passthrough.yml @@ -112,6 +112,53 @@ jobs: echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "schema_version=$SCHEMA_VERSION" >> $GITHUB_OUTPUT + - name: Install apt packages declared in manifest + env: + MANIFEST_PATH: ${{ inputs.manifest }} + run: | + set -euo pipefail + # Pull `build.aptPackages` from the manifest if present. + PKGS=$(jq -r '(.build.aptPackages // []) | join(" ")' "$MANIFEST_PATH") + if [ -z "$PKGS" ]; then + echo "No build.aptPackages in manifest; skipping apt install" + exit 0 + fi + echo "Installing: $PKGS" + sudo apt-get update -qq + # shellcheck disable=SC2086 + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $PKGS + + - name: Run build.script (if declared) + env: + MANIFEST_PATH: ${{ inputs.manifest }} + SHORT_SHA: ${{ steps.manifest.outputs.short_sha }} + SLUG: ${{ steps.manifest.outputs.slug }} + run: | + set -euo pipefail + # `build.script` is an optional path to an executable that + # transforms source → upload-ready artifacts in-place under + # ${{ inputs.source-dir }}. + BUILD_SCRIPT=$(jq -r '.build.script // empty' "$MANIFEST_PATH") + if [ -z "$BUILD_SCRIPT" ]; then + echo "No build.script in manifest; skipping pre-upload transform" + exit 0 + fi + if [ ! -f "$BUILD_SCRIPT" ]; then + echo "::error::build.script declared as '$BUILD_SCRIPT' but file not found" + exit 1 + fi + if [ ! -x "$BUILD_SCRIPT" ]; then + echo "Marking $BUILD_SCRIPT as executable" + chmod +x "$BUILD_SCRIPT" + fi + echo "Running $BUILD_SCRIPT (source-dir=${{ inputs.source-dir }})" + # The script gets the manifest values as env vars in case it needs them. + export EMBED_SLUG="$SLUG" + export EMBED_VERSION="$SHORT_SHA" + export EMBED_SOURCE_DIR="${{ inputs.source-dir }}" + time "./$BUILD_SCRIPT" + echo "Build script complete; staged outputs ready for upload" + - name: Stage source directory id: stage run: |