mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description `RELEASE_PLEASE_TOKEN` is currently a maintainer's personal PAT. It bypasses branch and tag protection on `main` (`release-please.yml` says so in its own comment), and forging a tag with it fires `release.yml` and `docker.yml` on `release: published`, which publish to PyPI, npm and GHCR. If it is a classic token with `repo` scope it is also valid against every other repository that account can reach. `release-metadata-sync.yml` made that credential readable on the runner. `actions/checkout` defaults to `persist-credentials: true`, writing the token into `.git/config`, and the very next step runs `scripts/version-sync.py` **from the checked-out branch**. The trigger is a push to the glob `release-please--branches--**`, which is not a protected namespace, so a principal with push access could land a modified `version-sync.py` and read it. Closes #2955. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Changes Made - Both release workflows now prefer a GitHub App installation token — scoped to this repository, expiring in an hour — over the PAT, via `actions/create-github-app-token@v3`. - The minting step is gated on `vars.RELEASE_APP_ID` and marked `continue-on-error`, so an unconfigured app falls through to the existing `PAT -> GITHUB_TOKEN` chain and nothing breaks today. - `release-metadata-sync.yml`'s checkout no longer persists credentials, and no longer receives a token at all. - The final push supplies the credential through the step's own `env` and an explicit remote URL, so it is never on disk while branch-supplied code runs. ## Testing - [x] Unit tests pass - [x] Linting passes (ruff check + format on the test file) - [ ] Type checking passes — N/A (YAML + test only) - [x] New tests added for new functionality ### Test Output ```text $ .venv/bin/python -m pytest tests/test_release_workflows.py -q 1 failed, 44 passed, 1 skipped in 0.23s ``` The single failure is `test_no_native_tls_in_wheel_build_tree`, which shells out to `cargo`. It reproduces identically on unmodified `main` on this machine (no Rust toolchain installed) and is unrelated to this change. New tests only: ```text $ .venv/bin/python -m pytest tests/test_release_workflows.py -q -k "persist_credentials or scoped_app_token" 3 passed, 46 deselected in 0.18s ``` Against the parent commit: ```text FAILED test_metadata_sync_does_not_persist_credentials_for_branch_supplied_code FAILED test_release_workflows_prefer_scoped_app_token[release-please.yml-release-please] FAILED test_release_workflows_prefer_scoped_app_token[release-metadata-sync.yml-sync] 3 failed, 46 deselected ``` ## Real Behavior Proof - Environment: macOS 15 (darwin 25.4.0), Python 3.12.13; workflows parsed with PyYAML, not executed on a runner. - Exact command / steps: parse both workflow files and assert (a) every `actions/checkout` step sets `persist-credentials: false` and receives no `token`, (b) exactly one gated `create-github-app-token` step exists per workflow, and (c) every credential consumer places `steps.app-token.outputs.token` ahead of `secrets.RELEASE_PLEASE_TOKEN` in its fallback chain. - Observed result: all three assertions pass on this branch and fail on the parent commit. Both files remain valid YAML. - **Not tested — important:** none of this has executed on a GitHub runner. I have not minted a real installation token, not confirmed the app-token step's `continue-on-error` fallback behaves as expected when `vars.RELEASE_APP_ID` is unset, and not performed a real push with the explicit-remote-URL form. The first live release run is the real test. ## Runtime Rollout Safety - Rollout-managed feature(s): none. - Minimum rollout channel: N/A. - Stable/default behavior changed: no, unless `vars.RELEASE_APP_ID` is set — without it both workflows resolve to exactly today's credential chain. - Kill switch / disable path: unset `vars.RELEASE_APP_ID` to fall back to the PAT. - Unsafe override required: none. - Qualification impact: none. - Rollback path: revert this commit. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes ## Additional Notes **This narrows blast radius; it does not make the trigger safe on its own.** For an `on: push` workflow GitHub reads the workflow file from the pushed ref, so a principal with push access can still edit this file on their branch. The durable fix is the scoped app token *plus revoking the personal PAT* — the revocation is a console action and is deliberately not in this commit. **Two repo settings are required to actually complete #2955**, and neither can land in git: ``` vars.RELEASE_APP_ID (repository variable) secrets.RELEASE_APP_PRIVATE_KEY (repository secret) ``` Until those exist this PR is a no-op on behavior and a defense-in-depth improvement on the `persist-credentials` path only. Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
111 lines
4.9 KiB
YAML
111 lines
4.9 KiB
YAML
name: Release Metadata Sync
|
|
|
|
# Keep generated version-carrying files in sync on release-please's branch.
|
|
#
|
|
# Why this exists
|
|
# ---------------
|
|
# release-please only rewrites `pyproject.toml` plus the `extra-files` listed in
|
|
# `.release-please-config.json` (currently the TypeScript SDK and OpenClaw
|
|
# package.json). Several other tracked files also carry the version, and
|
|
# `server.json` is asserted byte-for-byte against `render_server_json()` — which
|
|
# derives its version from `pyproject.toml`. So the moment release-please bumps
|
|
# the version, `tests/test_mcp_registry/test_server_json.py::
|
|
# test_root_server_json_matches_builder` fails on the release PR, and the release
|
|
# cannot be merged. That is what blocked v0.33.0 (PR #2339).
|
|
#
|
|
# `release.yml` already runs `scripts/version-sync.py` before its own
|
|
# `verify-versions.py` gate, so the release *build* self-heals in the workspace.
|
|
# The regular CI test job does not, so the fix has to be committed.
|
|
#
|
|
# Why a workflow rather than more `extra-files` entries
|
|
# ----------------------------------------------------
|
|
# `scripts/version-sync.py` is the single place that knows every version-carrying
|
|
# file. Restating that list as per-file jsonpaths would duplicate it, and a
|
|
# jsonpath that silently fails to match produces exactly the failure we are trying
|
|
# to remove. Running the script instead means files added to it in future are
|
|
# covered with no change here.
|
|
#
|
|
# Why the push trigger
|
|
# --------------------
|
|
# release-please regenerates (force-pushes) its branch on every merge to main.
|
|
# That is what repeatedly wiped the hand-pushed metadata fixes on #2339. Keying
|
|
# off a push to the branch means the sync re-applies after every regeneration
|
|
# instead of being lost.
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- "release-please--branches--**"
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
concurrency:
|
|
# Never cancel: a half-applied sync would leave the release PR inconsistent.
|
|
group: release-metadata-sync-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
sync:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
steps:
|
|
# Prefer a short-lived, repo-scoped GitHub App installation token over a
|
|
# personal PAT. Gated on the repo variable so an unconfigured app simply
|
|
# falls through to the existing chain instead of breaking the release.
|
|
- name: Mint installation token
|
|
id: app-token
|
|
if: ${{ vars.RELEASE_APP_ID != '' }}
|
|
continue-on-error: true
|
|
uses: actions/create-github-app-token@v3
|
|
with:
|
|
app-id: ${{ vars.RELEASE_APP_ID }}
|
|
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
|
|
|
- uses: actions/checkout@v7
|
|
with:
|
|
ref: ${{ github.ref_name }}
|
|
# Do NOT persist the credential into .git/config. The next step runs
|
|
# scripts/version-sync.py *from the checked-out branch*, and this job
|
|
# triggers on a push to the unprotected glob release-please--branches--**.
|
|
# A persisted token would be readable by that script.
|
|
persist-credentials: false
|
|
|
|
- uses: actions/setup-python@v6
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
# version-sync.py is stdlib-only (json/re/tomllib), so no install step.
|
|
- name: Sync version-carrying files release-please does not bump
|
|
run: python scripts/version-sync.py
|
|
|
|
- name: Verify all versions agree
|
|
run: python scripts/verify-versions.py
|
|
|
|
- name: Commit and push if anything changed
|
|
env:
|
|
# An app installation token if one was minted, else the existing
|
|
# chain. A PAT (not GITHUB_TOKEN) is still preferred here for the same
|
|
# reason release-please.yml wants one: a push made with GITHUB_TOKEN
|
|
# does not trigger workflows, so the release PR's checks would never
|
|
# re-run against the synced commit and would stay red. Supplied only
|
|
# to this step, after the branch-supplied script has already run.
|
|
SYNC_TOKEN: ${{ steps.app-token.outputs.token || secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
if git diff --quiet; then
|
|
echo "Already in sync — nothing to commit."
|
|
exit 0
|
|
fi
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
git add -A
|
|
git commit -m "chore: sync generated version metadata"
|
|
# Push via an explicit remote URL because the checkout no longer
|
|
# persists credentials. Passed on stdin-free env expansion so the
|
|
# token is not written to the command line or into .git/config.
|
|
# This push re-triggers this workflow. version-sync.py is idempotent, so
|
|
# the next run finds no diff and exits above without pushing — the loop
|
|
# terminates after one no-op run.
|
|
git push \
|
|
"https://x-access-token:${SYNC_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
|
|
HEAD:"${GITHUB_REF_NAME}"
|