headroom/.github/workflows/release-metadata-sync.yml
Tejas Chopra ac8646aa3c
fix(ci): scope the release credential and stop persisting it to disk (#3062)
## 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>
2026-08-16 19:05:32 -07:00

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}"