282 lines
13 KiB
YAML
282 lines
13 KiB
YAML
# A maintainer comments `/regen` on a PR to regenerate the repo's lockfiles
|
||
# (uv.lock + web/package-lock.json) against public PyPI/npm and commit them
|
||
# ONTO that PR's branch. Use when the PR itself moved a dependency; complements
|
||
# oss-regenerate-and-smoke.yml (standalone rolling PR on dispatch).
|
||
#
|
||
# Two forms:
|
||
# /regen re-resolve, preserving existing pins.
|
||
# /regen upgrade <pkg> [pkg] additionally force uv to take the newest allowed
|
||
# version of each named package (uv lock
|
||
# --upgrade-package). Use for a transitive pip
|
||
# security bump Dependabot can't land on this uv
|
||
# workspace (plain `uv lock` keeps the old pin).
|
||
#
|
||
# Validation is left to the PR's own CI: the push uses a GitHub App token (NOT
|
||
# GITHUB_TOKEN, which GitHub suppresses to avoid loops), so it re-fires the full
|
||
# check suite on the new commit. Falls back to GITHUB_TOKEN if the App isn't
|
||
# configured (lands, but a maintainer must re-push to run CI).
|
||
#
|
||
# Authorization: only .github/MAINTAINER entries (read from main's tip) may run
|
||
# it — it pushes code. Same-repo PRs only (can't push to a fork branch).
|
||
name: OSS regenerate lockfiles on /regen comment
|
||
|
||
on:
|
||
issue_comment:
|
||
types: [created]
|
||
|
||
# Read-only at the top level; write scopes live on the jobs below.
|
||
permissions:
|
||
contents: read
|
||
|
||
jobs:
|
||
# Gate: confirm a `/regen` comment on a PR in the OSS repo by a maintainer.
|
||
# Exposes the PR head ref to the regen job.
|
||
authorize:
|
||
permissions:
|
||
contents: read # checkout main for load-maintainers.sh
|
||
pull-requests: write # read the PR head ref, post the fork-rejection comment
|
||
issues: write # react to the triggering comment
|
||
if: >-
|
||
github.repository == 'omnigent-ai/omnigent'
|
||
&& github.event.issue.pull_request
|
||
&& startsWith(github.event.comment.body, '/regen')
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 5
|
||
outputs:
|
||
ok: ${{ steps.authz.outputs.ok }}
|
||
head: ${{ steps.pr.outputs.head }}
|
||
cross: ${{ steps.pr.outputs.cross }}
|
||
mode: ${{ steps.mode.outputs.mode }}
|
||
pkgs: ${{ steps.mode.outputs.pkgs }}
|
||
steps:
|
||
# Checkout main only for load-maintainers.sh; the PR branch is checked
|
||
# out later (regen job), after authorization passes.
|
||
- name: Checkout (for the maintainer script)
|
||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
- name: Load maintainers from .github/MAINTAINER
|
||
id: maint
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
REPO: ${{ github.repository }}
|
||
run: .github/scripts/merge-ready/load-maintainers.sh
|
||
|
||
- name: Authorize commenter
|
||
id: authz
|
||
env:
|
||
LIST: ${{ steps.maint.outputs.list }}
|
||
ACTOR: ${{ github.event.comment.user.login }}
|
||
run: |
|
||
ok=false
|
||
for u in $LIST; do
|
||
if [ "$u" = "$ACTOR" ]; then ok=true; break; fi
|
||
done
|
||
echo "ok=$ok" >> "$GITHUB_OUTPUT"
|
||
if [ "$ok" != "true" ]; then
|
||
echo "::notice::@$ACTOR is not in .github/MAINTAINER; ignoring /regen."
|
||
fi
|
||
|
||
# Parse an optional `upgrade <pkg...>` subcommand. Plain `/regen` keeps the
|
||
# default behaviour (re-resolve preserving pins). `/regen upgrade foo bar`
|
||
# asks uv to take the newest allowed version of foo + bar (a transitive
|
||
# security bump Dependabot can't land on this uv workspace). The comment
|
||
# body is read from env (never interpolated) and every package token is
|
||
# validated against a strict PEP 503-ish pattern, so nothing attacker-
|
||
# supplied can reach the shell in the regen job.
|
||
- name: Parse regen mode
|
||
id: mode
|
||
if: steps.authz.outputs.ok == 'true'
|
||
env:
|
||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||
run: |
|
||
python3 <<'PYEOF'
|
||
import os, re, pathlib
|
||
tokens = os.environ.get("COMMENT_BODY", "").split()
|
||
mode, pkgs = "regen", []
|
||
if len(tokens) >= 2 and tokens[0] == "/regen" and tokens[1] == "upgrade":
|
||
mode = "upgrade"
|
||
for t in tokens[2:]:
|
||
# uv package names only; drop anything else (never shelled).
|
||
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", t):
|
||
pkgs.append(t)
|
||
out = pathlib.Path(os.environ["GITHUB_OUTPUT"])
|
||
with out.open("a") as f:
|
||
f.write(f"mode={mode}\n")
|
||
f.write("pkgs=" + " ".join(pkgs) + "\n")
|
||
print(f"mode={mode} pkgs={pkgs}")
|
||
PYEOF
|
||
|
||
- name: Resolve PR head ref
|
||
id: pr
|
||
if: steps.authz.outputs.ok == 'true'
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
data=$(gh pr view "${{ github.event.issue.number }}" \
|
||
--repo "${{ github.repository }}" \
|
||
--json headRefName,isCrossRepository)
|
||
echo "head=$(echo "$data" | jq -r .headRefName)" >> "$GITHUB_OUTPUT"
|
||
echo "cross=$(echo "$data" | jq -r .isCrossRepository)" >> "$GITHUB_OUTPUT"
|
||
|
||
# ${{ }} values pass via env: and referenced as "$VAR" to avoid injection.
|
||
- name: Acknowledge (or reject forks)
|
||
if: steps.authz.outputs.ok == 'true'
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
CROSS: ${{ steps.pr.outputs.cross }}
|
||
ISSUE: ${{ github.event.issue.number }}
|
||
REPO: ${{ github.repository }}
|
||
COMMENT_ID: ${{ github.event.comment.id }}
|
||
run: |
|
||
if [ "$CROSS" = "true" ]; then
|
||
gh pr comment "$ISSUE" --repo "$REPO" \
|
||
--body "⚠️ \`/regen\` supports same-repo PRs only (it can't push to a fork branch). Regenerate locally with \`uv lock\` and push."
|
||
else
|
||
gh api "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \
|
||
-f content=eyes --silent || true
|
||
fi
|
||
|
||
regen:
|
||
permissions:
|
||
contents: write # push the regenerated lockfiles to the PR branch
|
||
pull-requests: write # post status comments
|
||
issues: write # comment the result on the PR thread
|
||
needs: authorize
|
||
if: needs.authorize.outputs.ok == 'true' && needs.authorize.outputs.cross == 'false'
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 20
|
||
# One regen per PR at a time; a second /regen waits rather than racing a push.
|
||
concurrency:
|
||
group: oss-regen-comment-${{ github.event.issue.number }}
|
||
cancel-in-progress: false
|
||
steps:
|
||
# No token / no persisted credentials: `uv lock` can execute PR-chosen
|
||
# build backends, which must not find a push token on disk. The App token
|
||
# is minted only after `uv lock` and enters only at the push step.
|
||
- name: Checkout the PR branch
|
||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
with:
|
||
ref: ${{ needs.authorize.outputs.head }}
|
||
persist-credentials: false
|
||
|
||
- name: Set up uv (clean public resolution, no proxy cache)
|
||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||
with:
|
||
enable-cache: false
|
||
|
||
- name: Set up Node
|
||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||
with:
|
||
node-version: "20"
|
||
|
||
# 7-day cooldown comes from uv.toml (`exclude-newer = "P7D"`), recorded as
|
||
# a relative span; an env-var cutoff would stamp an absolute date and break
|
||
# later `uv sync --locked`. npm's cooldown (web/.npmrc min-release-age=7)
|
||
# is only honored by npm >= 11.10.0; node 20 ships npm 10.x which ignores it.
|
||
# Pin the EXACT version (not a range) and keep it in lockstep with
|
||
# .github/actions/setup-node (npm 11.12.1): this workflow generates the
|
||
# lockfile and that action verifies it, so a version gap would fail the
|
||
# freshness gate in lint.yml.
|
||
- name: Ensure npm honors the dependency cooldown
|
||
run: npm install -g npm@11.12.1
|
||
# Delete package-lock.json so npm RESOLVES from scratch: min-release-age
|
||
# only filters during resolution, and --package-lock-only keeps an existing
|
||
# in-range pin without re-applying the cooldown.
|
||
# --legacy-peer-deps is REQUIRED and MUST match the flag lint.yml verifies
|
||
# with (React 18 runtime vs React 19 peers would otherwise ERESOLVE-fail,
|
||
# and a flag mismatch rewrites dev/extraneous flags, failing the gate).
|
||
- name: Regenerate lockfiles against public PyPI/npm
|
||
env:
|
||
REGEN_MODE: ${{ needs.authorize.outputs.mode }}
|
||
UPGRADE_PKGS: ${{ needs.authorize.outputs.pkgs }}
|
||
run: |
|
||
# Default `/regen`: re-resolve preserving existing pins.
|
||
# `/regen upgrade <pkgs...>`: force uv to take the newest allowed
|
||
# version for each named package (e.g. a transitive security fix).
|
||
# UPGRADE_PKGS holds only strictly-validated names (see the authorize
|
||
# job's Parse step), so word-splitting it here is safe.
|
||
if [ "$REGEN_MODE" = "upgrade" ] && [ -n "$UPGRADE_PKGS" ]; then
|
||
args=()
|
||
for p in $UPGRADE_PKGS; do args+=(--upgrade-package "$p"); done
|
||
echo "uv lock ${args[*]}"
|
||
uv lock "${args[@]}"
|
||
else
|
||
uv lock
|
||
fi
|
||
( cd web && rm -f package-lock.json && npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund )
|
||
|
||
# Mint the App token only AFTER `uv lock` so untrusted PR build backends
|
||
# never see it. Skipped when the App isn't configured (push then falls back
|
||
# to GITHUB_TOKEN and a maintainer must re-push to run CI).
|
||
- name: Mint App token
|
||
id: app-token
|
||
if: vars.OMNIGENT_BOT_APP_ID != ''
|
||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||
with:
|
||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||
|
||
# ${{ }} values pass via env: as "$VAR" to avoid injection (HEAD_REF is a
|
||
# user-influenced branch name). The push token authenticates inline (scoped
|
||
# to this step, never in .git/config) so the push re-triggers the PR's CI.
|
||
- name: Commit and push to the PR branch
|
||
id: push
|
||
env:
|
||
HEAD_REF: ${{ needs.authorize.outputs.head }}
|
||
PUSH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
|
||
REPO: ${{ github.repository }}
|
||
run: |
|
||
git config user.name "omnigent-ci[bot]"
|
||
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
|
||
# --porcelain (not git diff) so first-time UNTRACKED lockfiles count too.
|
||
if [ -z "$(git status --porcelain -- uv.lock web/package-lock.json)" ]; then
|
||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||
echo "Lockfiles already current — nothing to commit."
|
||
exit 0
|
||
fi
|
||
git add uv.lock web/package-lock.json
|
||
git commit -m "chore(oss): regenerate public lockfiles against public PyPI/npm"
|
||
git push "https://x-access-token:${PUSH_TOKEN}@github.com/${REPO}.git" "HEAD:$HEAD_REF"
|
||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Comment the result
|
||
if: always() && steps.push.conclusion == 'success'
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
ISSUE: ${{ github.event.issue.number }}
|
||
REPO: ${{ github.repository }}
|
||
CHANGED: ${{ steps.push.outputs.changed }}
|
||
REGEN_MODE: ${{ needs.authorize.outputs.mode }}
|
||
UPGRADE_PKGS: ${{ needs.authorize.outputs.pkgs }}
|
||
# App token used → push re-triggers CI; skipped (GITHUB_TOKEN fallback) → it won't.
|
||
APP_USED: ${{ steps.app-token.conclusion == 'success' }} # App token → re-triggers CI; fallback → won't
|
||
run: |
|
||
upgraded=""
|
||
if [ "$REGEN_MODE" = "upgrade" ] && [ -n "$UPGRADE_PKGS" ]; then
|
||
upgraded=" (upgraded: $UPGRADE_PKGS)"
|
||
fi
|
||
if [ "$CHANGED" = "true" ]; then
|
||
base="✅ Regenerated \`uv.lock\`$upgraded + \`web/package-lock.json\` against public PyPI/npm and pushed to this PR."
|
||
if [ "$APP_USED" = "true" ]; then
|
||
body="$base CI will re-run on the new commit."
|
||
else
|
||
body="$base ⚠️ No regen App configured, so this push won't auto-trigger CI — push any commit (or amend) to re-run checks."
|
||
fi
|
||
gh pr comment "$ISSUE" --repo "$REPO" --body "$body"
|
||
else
|
||
gh pr comment "$ISSUE" --repo "$REPO" \
|
||
--body "ℹ️ Lockfiles already current against public PyPI/npm — nothing to regenerate."
|
||
fi
|
||
|
||
# Failure path: tell the maintainer on the PR instead of the Actions tab.
|
||
- name: Comment on failure
|
||
if: failure()
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
ISSUE: ${{ github.event.issue.number }}
|
||
REPO: ${{ github.repository }}
|
||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||
run: |
|
||
gh pr comment "$ISSUE" --repo "$REPO" \
|
||
--body "❌ \`/regen\` failed — see the [workflow run]($RUN_URL). Lockfiles were not changed."
|