chore: import upstream snapshot with attribution
Auto Update PR / update-prs (push) Has been cancelled
CI / format-check (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / live-api-tests (push) Has been cancelled
CI / plugin-integration-test (push) Has been cancelled
CI / ollama-integration-test (push) Has been cancelled
CI / test-fork-pr (push) Has been cancelled
Auto Update PR / update-prs (push) Has been cancelled
CI / format-check (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / live-api-tests (push) Has been cancelled
CI / plugin-integration-test (push) Has been cancelled
CI / ollama-integration-test (push) Has been cancelled
CI / test-fork-pr (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a bug report to help us improve
|
||||
title: 'Bug: <brief title of your issue>'
|
||||
labels: 'bug', 'needs triage'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Describe the overall issue and situation
|
||||
|
||||
Provide a clear summary of what the issue is about, the area of the project you
|
||||
found it in, and what you were trying to do.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
Provide a clear and concise description of what you expected to happen
|
||||
|
||||
## Actual behavior
|
||||
|
||||
Provide a clear and concise description of what actually happened.
|
||||
|
||||
## Steps to reproduce the issue
|
||||
|
||||
Provide a sequence of steps we can use to reproduce the issue.
|
||||
|
||||
1. <First step...>
|
||||
2. <Second step...>
|
||||
3. <Third step...>
|
||||
|
||||
## Any additional content
|
||||
|
||||
Describe your environment or any other set up details that might help us
|
||||
reproduce the issue.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea or improvement
|
||||
title: 'Request: <brief title of your feature request>'
|
||||
labels: 'enhancement', 'needs triage'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Describe the overall idea and motivation
|
||||
|
||||
Provide a clear summary of the idea and what use cases it's addressing.
|
||||
|
||||
## Related to an issue?
|
||||
|
||||
Is this addressing a known / documented issue? If so, which one?
|
||||
|
||||
## Possible solutions and alternatives
|
||||
|
||||
Do you already have an idea of how the solution should work? If so, document
|
||||
that here.
|
||||
|
||||
Also, if there are alternatives, please document those as well.
|
||||
|
||||
## Priority and timeline considerations
|
||||
|
||||
Is this time sensitive? Is it a nice to have? Please describe what priority you
|
||||
feel this should have and why. We'll take this into advisement as we go through
|
||||
our internal prioritization process.
|
||||
|
||||
## Additional context
|
||||
|
||||
Is there anything else to consider that wasn't covered by the above?
|
||||
|
||||
Would you like to contribute to the project and work on this request?
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Allow users to create issues that don't follow the templates since they don't cover all use cases
|
||||
blank_issues_enabled: true
|
||||
|
||||
# Redirect users to other channels for general support or security issues
|
||||
contact_links:
|
||||
- name: Community Support
|
||||
url: https://github.com/google/langextract/discussions
|
||||
about: Please ask and answer questions here.
|
||||
- name: Security Bug Reporting
|
||||
url: https://g.co/vulnz
|
||||
about: >
|
||||
To report a security issue, please use https://g.co/vulnz. The Google Security Team will
|
||||
respond within 5 working days of your report on https://g.co/vulnz.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Description
|
||||
|
||||
Replace this with a clear and concise change description
|
||||
|
||||
<!--- Important: All PRs must be linked to at least one issue (except for
|
||||
extremely trivial and straightforward changes). --->
|
||||
|
||||
<!--- This issue (or issues) should document the motivation, context,
|
||||
alternatives considered, risks (such as breaking backwards compatibility), and
|
||||
any new dependencies. --->
|
||||
|
||||
<!--- Use "Fixes #123" to auto-close the issue when merged (for bug fixes/implementations) -->
|
||||
<!--- Use "Related to #123" or "Addresses #123" for documentation updates or partial solutions -->
|
||||
Fixes/Related to #[issue number]
|
||||
|
||||
Choose one: (Bug fix | Feature | Documentation | Testing | Code health | Other)
|
||||
|
||||
# How Has This Been Tested?
|
||||
|
||||
Replace this with a description of the tests that you ran to verify your
|
||||
changes. If executing the existing test suite without customization, simply
|
||||
paste the command line used.
|
||||
|
||||
```
|
||||
$ python -m unittest discover ...
|
||||
```
|
||||
|
||||
# Checklist:
|
||||
|
||||
<!--- Put an `x` in the box if you did the task -->
|
||||
|
||||
<!--- If you forgot a task please follow the instructions below -->
|
||||
|
||||
- [ ] I have read and acknowledged Google's Open Source
|
||||
[Code of conduct](https://opensource.google/conduct).
|
||||
- [ ] I have read the
|
||||
[Contributing](https://github.com/google-health/langextract/blob/master/CONTRIBUTING.md)
|
||||
page, and I either signed the Google
|
||||
[Individual CLA](https://cla.developers.google.com/about/google-individual)
|
||||
or am covered by my company's
|
||||
[Corporate CLA](https://cla.developers.google.com/about/google-corporate).
|
||||
- [ ] I have discussed my proposed solution with code owners in the linked
|
||||
issue(s) and we have agreed upon the general approach.
|
||||
- [ ] I have made any needed documentation changes, or noted in the linked
|
||||
issue(s) that documentation elsewhere needs updating.
|
||||
- [ ] I have added tests, or I have ensured existing tests cover the changes
|
||||
- [ ] I have followed
|
||||
[Google's Python Style Guide](https://google.github.io/styleguide/pyguide.html)
|
||||
and ran `pylint` over the affected code.
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Script to add new required status checks to an existing branch protection rule.
|
||||
# This preserves all your current settings and just adds the new checks
|
||||
|
||||
echo "Adding new PR validation checks to existing branch protection..."
|
||||
|
||||
# Add the new checks to existing ones
|
||||
echo "Adding new checks: enforce, size, and protect-infrastructure..."
|
||||
gh api repos/:owner/:repo/branches/main/protection/required_status_checks/contexts \
|
||||
--method POST \
|
||||
--input - <<< '["enforce", "size", "protect-infrastructure"]'
|
||||
|
||||
echo ""
|
||||
echo "✓ New checks added!"
|
||||
echo ""
|
||||
echo "Updated required status checks will include:"
|
||||
echo "- test (3.10) [existing]"
|
||||
echo "- test (3.11) [existing]"
|
||||
echo "- test (3.12) [existing]"
|
||||
echo "- Validate PR Template [existing]"
|
||||
echo "- live-api-tests [existing]"
|
||||
echo "- ollama-integration-test [existing]"
|
||||
echo "- enforce [NEW - linked issue validation]"
|
||||
echo "- size [NEW - PR size limit]"
|
||||
echo "- protect-infrastructure [NEW - infrastructure file protection]"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Add size labels to PRs based on their change count
|
||||
|
||||
echo "Adding size labels to PRs..."
|
||||
|
||||
# Get all open PRs with their additions and deletions
|
||||
gh pr list --limit 50 --json number,additions,deletions --jq '.[]' | while read -r pr_data; do
|
||||
pr_number=$(echo "$pr_data" | jq -r '.number')
|
||||
additions=$(echo "$pr_data" | jq -r '.additions')
|
||||
deletions=$(echo "$pr_data" | jq -r '.deletions')
|
||||
total_changes=$((additions + deletions))
|
||||
|
||||
# Determine size label
|
||||
if [ $total_changes -lt 50 ]; then
|
||||
size_label="size/XS"
|
||||
elif [ $total_changes -lt 150 ]; then
|
||||
size_label="size/S"
|
||||
elif [ $total_changes -lt 600 ]; then
|
||||
size_label="size/M"
|
||||
elif [ $total_changes -lt 1000 ]; then
|
||||
size_label="size/L"
|
||||
else
|
||||
size_label="size/XL"
|
||||
fi
|
||||
|
||||
echo "PR #$pr_number: $total_changes lines -> $size_label"
|
||||
|
||||
# Remove any existing size labels first
|
||||
existing_labels=$(gh pr view $pr_number --json labels --jq '.labels[].name' | grep "^size/" || true)
|
||||
if [ ! -z "$existing_labels" ]; then
|
||||
echo " Removing existing label: $existing_labels"
|
||||
gh pr edit $pr_number --remove-label "$existing_labels"
|
||||
fi
|
||||
|
||||
# Add the new size label
|
||||
gh pr edit $pr_number --add-label "$size_label"
|
||||
|
||||
sleep 1 # Avoid rate limiting
|
||||
done
|
||||
|
||||
echo "Done adding size labels!"
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Revalidate all open PRs
|
||||
|
||||
echo "Fetching all open PRs..."
|
||||
PR_NUMBERS=$(gh pr list --limit 50 --json number --jq '.[].number')
|
||||
TOTAL=$(echo "$PR_NUMBERS" | wc -w | tr -d ' ')
|
||||
|
||||
echo "Found $TOTAL open PRs"
|
||||
echo "Starting revalidation..."
|
||||
echo ""
|
||||
|
||||
COUNT=0
|
||||
for pr in $PR_NUMBERS; do
|
||||
COUNT=$((COUNT + 1))
|
||||
echo "[$COUNT/$TOTAL] Triggering revalidation for PR #$pr..."
|
||||
gh workflow run revalidate-pr.yml -f pr_number=$pr
|
||||
|
||||
# Small delay to avoid rate limiting
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "All workflows triggered!"
|
||||
echo ""
|
||||
echo "To monitor progress:"
|
||||
echo " gh run list --workflow=revalidate-pr.yml --limit=$TOTAL"
|
||||
echo ""
|
||||
echo "To see results, check comments on each PR"
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Publish a new version to Zenodo via the InvenioRDM Records API.
|
||||
|
||||
Zenodo migrated to InvenioRDM in late 2023. The legacy /api/deposit
|
||||
endpoints still exist but /actions/newversion now rejects records with
|
||||
files attached ("Please remove all files first" on field files.enabled).
|
||||
This script uses the modern /api/records flow instead.
|
||||
|
||||
Reads project name from pyproject.toml. ZENODO_RECORD_ID may be either
|
||||
the concept ID or any version's record ID — Zenodo resolves to the
|
||||
latest version.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.request
|
||||
|
||||
import requests
|
||||
|
||||
API = "https://zenodo.org/api"
|
||||
TOKEN = os.environ["ZENODO_TOKEN"]
|
||||
RECORD_ID = os.environ["ZENODO_RECORD_ID"]
|
||||
VERSION = os.environ["RELEASE_TAG"].lstrip("v")
|
||||
REPO = os.environ["GITHUB_REPOSITORY"]
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
ACCEPT_HEADERS = {**AUTH, "Accept": "application/vnd.inveniordm.v1+json"}
|
||||
JSON_HEADERS = {**ACCEPT_HEADERS, "Content-Type": "application/json"}
|
||||
|
||||
try:
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
pyproject = tomllib.load(f)
|
||||
PROJECT_META = pyproject["project"]
|
||||
PROJECT = PROJECT_META["name"]
|
||||
except (KeyError, FileNotFoundError) as e:
|
||||
print(f"❌ Error loading project metadata: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _check(r: requests.Response, op: str) -> None:
|
||||
"""raise_for_status with the response body included for debugging."""
|
||||
if not r.ok:
|
||||
body = r.text[:2000] if r.text else "<empty>"
|
||||
print(f"❌ Zenodo {op} failed: {r.status_code} {r.reason}", file=sys.stderr)
|
||||
print(f" URL: {r.request.url}", file=sys.stderr)
|
||||
print(f" Response body: {body}", file=sys.stderr)
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
def new_version_draft(record_id: str) -> dict:
|
||||
"""Create a new version draft via the InvenioRDM Records API.
|
||||
|
||||
If a draft already exists for the record, the API returns it instead
|
||||
of creating a duplicate.
|
||||
"""
|
||||
r = requests.post(
|
||||
f"{API}/records/{record_id}/versions",
|
||||
headers=JSON_HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
_check(r, "create new version")
|
||||
return r.json()
|
||||
|
||||
|
||||
def published_metadata(record_id: str) -> dict:
|
||||
"""Read required metadata from the latest published version."""
|
||||
r = requests.get(
|
||||
f"{API}/records/{record_id}",
|
||||
headers=ACCEPT_HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
_check(r, "GET published record")
|
||||
return r.json().get("metadata", {})
|
||||
|
||||
|
||||
def release_date() -> str | None:
|
||||
"""Return the release date from CITATION.cff, if present."""
|
||||
try:
|
||||
with open("CITATION.cff", encoding="utf-8") as f:
|
||||
match = re.search(
|
||||
r"^date-released:\s*([0-9]{4}-[0-9]{2}-[0-9]{2})$",
|
||||
f.read(),
|
||||
re.MULTILINE,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def upload_file(draft_id: str, path: str, dest_name: str = None) -> None:
|
||||
"""Register, upload, and commit a file on a draft (3-step RDM flow)."""
|
||||
dest = dest_name or os.path.basename(path)
|
||||
files_url = f"{API}/records/{draft_id}/draft/files"
|
||||
|
||||
init = requests.post(
|
||||
files_url, headers=JSON_HEADERS, json=[{"key": dest}], timeout=30
|
||||
)
|
||||
if init.status_code == 400 and "already exists" in init.text.lower():
|
||||
# Re-running over an existing draft; remove the stale entry first.
|
||||
del_r = requests.delete(f"{files_url}/{dest}", headers=AUTH, timeout=30)
|
||||
_check(del_r, f"delete stale file {dest}")
|
||||
init = requests.post(
|
||||
files_url, headers=JSON_HEADERS, json=[{"key": dest}], timeout=30
|
||||
)
|
||||
_check(init, f"register file {dest}")
|
||||
|
||||
with open(path, "rb") as fp:
|
||||
up = requests.put(
|
||||
f"{files_url}/{dest}/content",
|
||||
data=fp,
|
||||
headers={**AUTH, "Content-Type": "application/octet-stream"},
|
||||
timeout=300,
|
||||
)
|
||||
_check(up, f"upload {dest}")
|
||||
|
||||
commit = requests.post(f"{files_url}/{dest}/commit", headers=AUTH, timeout=30)
|
||||
_check(commit, f"commit {dest}")
|
||||
|
||||
|
||||
def update_metadata(draft_id: str) -> None:
|
||||
"""Patch version-specific fields while preserving required metadata."""
|
||||
r = requests.get(
|
||||
f"{API}/records/{draft_id}/draft",
|
||||
headers=ACCEPT_HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
_check(r, "GET draft")
|
||||
draft_metadata = r.json().get("metadata", {})
|
||||
source_metadata = published_metadata(RECORD_ID)
|
||||
metadata = dict(source_metadata)
|
||||
metadata.update(draft_metadata)
|
||||
for field in ("creators", "description", "publication_date"):
|
||||
if not metadata.get(field) and source_metadata.get(field):
|
||||
metadata[field] = source_metadata[field]
|
||||
metadata["title"] = f"{PROJECT.replace('-', ' ').title()} v{VERSION}"
|
||||
metadata["version"] = VERSION
|
||||
if released := release_date():
|
||||
metadata["publication_date"] = released
|
||||
# InvenioRDM resource type schema differs from the legacy upload_type
|
||||
# enum; "software" is the canonical id.
|
||||
metadata["resource_type"] = {"id": "software"}
|
||||
|
||||
r = requests.put(
|
||||
f"{API}/records/{draft_id}/draft",
|
||||
headers=JSON_HEADERS,
|
||||
json={"metadata": metadata},
|
||||
timeout=30,
|
||||
)
|
||||
_check(r, "PUT metadata")
|
||||
|
||||
|
||||
def publish_draft(draft_id: str) -> dict:
|
||||
r = requests.post(
|
||||
f"{API}/records/{draft_id}/draft/actions/publish",
|
||||
headers=JSON_HEADERS,
|
||||
timeout=60,
|
||||
)
|
||||
_check(r, "publish")
|
||||
return r.json()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
draft = new_version_draft(RECORD_ID)
|
||||
draft_id = draft["id"]
|
||||
print(f"ℹ️ Working on draft id={draft_id}", file=sys.stderr)
|
||||
|
||||
tarball = f"/tmp/{PROJECT}-v{VERSION}.tar.gz"
|
||||
src_url = f"{SERVER}/{REPO}/archive/refs/tags/v{VERSION}.tar.gz"
|
||||
urllib.request.urlretrieve(src_url, tarball)
|
||||
upload_file(draft_id, tarball, f"{PROJECT}-{VERSION}.tar.gz")
|
||||
|
||||
for path in glob.glob("dist/*"):
|
||||
upload_file(draft_id, path)
|
||||
|
||||
update_metadata(draft_id)
|
||||
record = publish_draft(draft_id)
|
||||
|
||||
doi = record.get("doi") or record.get("pids", {}).get("doi", {}).get(
|
||||
"identifier"
|
||||
)
|
||||
record_id = record.get("id")
|
||||
print(f"✅ Published to Zenodo: https://doi.org/{doi}")
|
||||
|
||||
if "GITHUB_OUTPUT" in os.environ:
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||
f.write(f"doi={doi}\n")
|
||||
f.write(f"record_id={record_id}\n")
|
||||
f.write(f"zenodo_url=https://zenodo.org/records/{record_id}\n")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,166 @@
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Auto Update PR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Run daily at 2 AM UTC to catch stale PRs
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to update (optional, updates all if not specified)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write # Required for updateBranch API
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
update-prs:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: auto-update-pr-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Update PRs that are behind main
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.payload.inputs?.pr_number;
|
||||
|
||||
// Get list of open PRs
|
||||
const prs = prNumber
|
||||
? [(await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: parseInt(prNumber)
|
||||
})).data]
|
||||
: await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
sort: 'updated',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
console.log(`Found ${prs.length} open PRs to check`);
|
||||
|
||||
// Constants for comment flood control
|
||||
const UPDATE_COMMENT_COOLDOWN_DAYS = 7;
|
||||
const COOLDOWN_MS = UPDATE_COMMENT_COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const pr of prs) {
|
||||
// Skip bot PRs and drafts
|
||||
if (pr.user.login.includes('[bot]')) {
|
||||
console.log(`Skipping bot PR #${pr.number} from ${pr.user.login}`);
|
||||
continue;
|
||||
}
|
||||
if (pr.draft) {
|
||||
console.log(`Skipping draft PR #${pr.number}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if PR is behind main (base...head comparison)
|
||||
const { data: comparison } = await github.rest.repos.compareCommits({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
base: pr.base.ref, // main branch
|
||||
head: `${pr.head.repo.owner.login}:${pr.head.ref}` // Fully qualified ref for forks
|
||||
});
|
||||
|
||||
if (comparison.behind_by > 0) {
|
||||
console.log(`PR #${pr.number} is ${comparison.behind_by} commits behind ${pr.base.ref}`);
|
||||
|
||||
// Check if the PR allows maintainer edits
|
||||
if (pr.maintainer_can_modify) {
|
||||
// Try to update the branch
|
||||
try {
|
||||
await github.rest.pulls.updateBranch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number
|
||||
});
|
||||
|
||||
console.log(`✅ Updated PR #${pr.number}`);
|
||||
|
||||
// Add a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: `🔄 **Branch Updated**\n\nYour branch was ${comparison.behind_by} commits behind \`${pr.base.ref}\` and has been automatically updated. CI checks will re-run shortly.`
|
||||
});
|
||||
} catch (updateError) {
|
||||
console.log(`Could not auto-update PR #${pr.number}: ${updateError.message}`);
|
||||
|
||||
// Determine the reason for failure
|
||||
let failureReason = '';
|
||||
if (updateError.status === 409 || updateError.message.includes('merge conflict')) {
|
||||
failureReason = '\n\n**Note:** Automatic update failed due to merge conflicts. Please resolve them manually.';
|
||||
} else if (updateError.status === 422) {
|
||||
failureReason = '\n\n**Note:** Cannot push to fork. Please update manually.';
|
||||
}
|
||||
|
||||
// Notify the contributor to update manually
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: `⚠️ **Branch Update Required**\n\nYour branch is ${comparison.behind_by} commits behind \`${pr.base.ref}\`.${failureReason}\n\nPlease update your branch:\n\n\`\`\`bash\ngit fetch origin ${pr.base.ref}\ngit merge origin/${pr.base.ref}\ngit push\n\`\`\`\n\nOr use GitHub's "Update branch" button if available.`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Can't modify, just notify
|
||||
console.log(`PR #${pr.number} doesn't allow maintainer edits`);
|
||||
|
||||
// Check if we already commented recently (within last 7 days)
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
since: new Date(Date.now() - COOLDOWN_MS).toISOString()
|
||||
});
|
||||
|
||||
const hasRecentUpdateComment = comments.some(c =>
|
||||
c.body?.includes('Branch Update Required') &&
|
||||
c.user?.login === 'github-actions[bot]'
|
||||
);
|
||||
|
||||
if (!hasRecentUpdateComment) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: `⚠️ **Branch Update Required**\n\nYour branch is ${comparison.behind_by} commits behind \`${pr.base.ref}\`. Please update your branch to ensure CI checks run with the latest code:\n\n\`\`\`bash\ngit fetch origin ${pr.base.ref}\ngit merge origin/${pr.base.ref}\ngit push\n\`\`\`\n\nNote: Enable "Allow edits by maintainers" to allow automatic updates.`
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`PR #${pr.number} is up to date`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing PR #${pr.number}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Log rate limit status
|
||||
const { data: rateLimit } = await github.rest.rateLimit.get();
|
||||
console.log(`API rate limit remaining: ${rateLimit.rate.remaining}/${rateLimit.rate.limit}`);
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Protect Infrastructure Files
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
protect-infrastructure:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check for infrastructure file changes
|
||||
if: github.event_name == 'pull_request_target'
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
// Get the PR author and check if they're a maintainer
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const { data: authorPermission } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: prAuthor
|
||||
});
|
||||
|
||||
const isMaintainer = ['admin', 'maintain'].includes(authorPermission.permission);
|
||||
|
||||
// Get list of files changed in the PR
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number
|
||||
});
|
||||
|
||||
// Check for infrastructure file changes
|
||||
const infrastructureFiles = files.filter(file =>
|
||||
file.filename.startsWith('.github/') ||
|
||||
file.filename === 'pyproject.toml' ||
|
||||
file.filename === 'tox.ini' ||
|
||||
file.filename === '.pre-commit-config.yaml' ||
|
||||
file.filename === '.pylintrc' ||
|
||||
file.filename === 'Dockerfile' ||
|
||||
file.filename === 'autoformat.sh' ||
|
||||
file.filename === '.gitignore' ||
|
||||
file.filename === 'CONTRIBUTING.md' ||
|
||||
file.filename === 'LICENSE' ||
|
||||
file.filename === 'CITATION.cff'
|
||||
);
|
||||
|
||||
if (infrastructureFiles.length > 0 && !isMaintainer) {
|
||||
// Check if changes are only formatting/whitespace
|
||||
let hasStructuralChanges = false;
|
||||
for (const file of infrastructureFiles) {
|
||||
const additions = file.additions || 0;
|
||||
const deletions = file.deletions || 0;
|
||||
const changes = file.changes || 0;
|
||||
|
||||
// If file has significant changes (not just whitespace), consider it structural
|
||||
if (additions > 5 || deletions > 5 || changes > 10) {
|
||||
hasStructuralChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fileList = infrastructureFiles.map(f => ` - ${f.filename} (${f.changes} changes)`).join('\n');
|
||||
|
||||
// Post a comment explaining the issue
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: `❌ **Infrastructure File Protection**\n\n` +
|
||||
`This PR modifies protected infrastructure files:\n\n${fileList}\n\n` +
|
||||
`Only repository maintainers are allowed to modify infrastructure files (including \`.github/\`, build configuration, and repository documentation).\n\n` +
|
||||
`**Note**: If these are only formatting changes, please:\n` +
|
||||
`1. Revert changes to \`.github/\` files\n` +
|
||||
`2. Use \`./autoformat.sh\` to format only source code directories\n` +
|
||||
`3. Avoid running formatters on infrastructure files\n\n` +
|
||||
`If structural changes are necessary:\n` +
|
||||
`1. Open an issue describing the needed infrastructure changes\n` +
|
||||
`2. A maintainer will review and implement the changes if approved\n\n` +
|
||||
`For more information, see our [Contributing Guidelines](https://github.com/google/langextract/blob/main/CONTRIBUTING.md).`
|
||||
});
|
||||
|
||||
core.setFailed(
|
||||
`This PR modifies ${infrastructureFiles.length} protected infrastructure file(s). ` +
|
||||
`Only maintainers can modify these files. ` +
|
||||
`Use ./autoformat.sh to format code without touching infrastructure.`
|
||||
);
|
||||
} else if (infrastructureFiles.length > 0 && isMaintainer) {
|
||||
core.info(`PR modifies ${infrastructureFiles.length} infrastructure file(s) - allowed for maintainer ${prAuthor}`);
|
||||
} else {
|
||||
core.info('No infrastructure files modified');
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
name: Require linked issue with community support
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
enforce:
|
||||
if: github.event_name == 'pull_request_target' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check linked issue and community support
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
// Strip code blocks and inline code to avoid false matches
|
||||
const stripCode = txt =>
|
||||
txt.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '');
|
||||
|
||||
// Combine title + body for comprehensive search
|
||||
const prText = stripCode(`${context.payload.pull_request.title || ''}\n${context.payload.pull_request.body || ''}`);
|
||||
|
||||
// Issue reference pattern: #123, org/repo#123, or full URL (with http/https and optional www)
|
||||
const issueRef = String.raw`(?:#(?<num>\d+)|(?<o1>[\w.-]+)\/(?<r1>[\w.-]+)#(?<n1>\d+)|https?:\/\/(?:www\.)?github\.com\/(?<o2>[\w.-]+)\/(?<r2>[\w.-]+)\/issues\/(?<n2>\d+))`;
|
||||
|
||||
// Keywords - supporting common variants
|
||||
const closingRe = new RegExp(String.raw`\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s+${issueRef}`, 'gi');
|
||||
const referenceRe = new RegExp(String.raw`\b(?:related\s+to|relates\s+to|refs?|part\s+of|addresses|see(?:\s+also)?|depends\s+on|blocked\s+by|supersedes)\b\s*:?\s+${issueRef}`, 'gi');
|
||||
|
||||
// Gather all matches
|
||||
const closings = [...prText.matchAll(closingRe)];
|
||||
const references = [...prText.matchAll(referenceRe)];
|
||||
const first = closings[0] || references[0];
|
||||
|
||||
// Check for draft PRs and bots
|
||||
const pr = context.payload.pull_request;
|
||||
const isDraft = !!pr.draft;
|
||||
const login = pr.user.login;
|
||||
const isBot = pr.user.type === 'Bot' || /\[bot\]$/.test(login);
|
||||
|
||||
if (isDraft || isBot) {
|
||||
core.info('Draft or bot PR – skipping enforcement');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if PR author is a maintainer
|
||||
let authorPerm = 'none';
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: pr.user.login,
|
||||
});
|
||||
authorPerm = data.permission || 'none';
|
||||
} catch (_) {
|
||||
// User might not have any permissions
|
||||
}
|
||||
|
||||
core.info(`Author permission: ${authorPerm}`);
|
||||
const isMaintainer = ['admin', 'maintain'].includes(authorPerm); // Removed 'write' for stricter maintainer definition
|
||||
|
||||
// Maintainers bypass entirely
|
||||
if (isMaintainer) {
|
||||
core.info(`Maintainer ${pr.user.login} - bypassing linked issue requirement`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!first) {
|
||||
// Check for existing comment to avoid duplicates
|
||||
const MARKER = '<!-- linkcheck:missing-issue -->';
|
||||
const existing = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyLeft = existing.some(c => c.body && c.body.includes(MARKER));
|
||||
|
||||
if (!alreadyLeft) {
|
||||
const contribUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md#pull-request-guidelines`;
|
||||
const commentBody = [
|
||||
'No linked issues found. Please link an issue in your pull request description or title.',
|
||||
'',
|
||||
`Per our [Contributing Guidelines](${contribUrl}), all PRs must:`,
|
||||
'- Reference an issue with one of:',
|
||||
' - **Closing keywords**: `Fixes #123`, `Closes #123`, `Resolves #123` (auto-closes on merge in the same repository)',
|
||||
' - **Reference keywords**: `Related to #123`, `Refs #123`, `Part of #123`, `See #123` (links without closing)',
|
||||
'- The linked issue should have 5+ 👍 reactions from unique users (excluding bots and the PR author)',
|
||||
'- Include discussion demonstrating the importance of the change',
|
||||
'',
|
||||
'You can also use cross-repo references like `owner/repo#123` or full URLs.',
|
||||
'',
|
||||
MARKER
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: commentBody
|
||||
});
|
||||
}
|
||||
|
||||
core.setFailed('No linked issue found. Use "Fixes #123" to close an issue or "Related to #123" to reference it.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve owner/repo/number, defaulting to the current repo
|
||||
const groups = first.groups || {};
|
||||
const owner = groups.o1 || groups.o2 || context.repo.owner;
|
||||
const repo = groups.r1 || groups.r2 || context.repo.repo;
|
||||
const issue_number = Number(groups.num || groups.n1 || groups.n2);
|
||||
|
||||
// Validate issue number
|
||||
if (!Number.isInteger(issue_number) || issue_number <= 0) {
|
||||
core.setFailed(
|
||||
'Found a potential issue link but no valid number. ' +
|
||||
'Use "Fixes #123" or "Related to owner/repo#123".'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Found linked issue: ${owner}/${repo}#${issue_number}`);
|
||||
|
||||
// Count unique users who reacted with 👍 on the linked issue (excluding bots and PR author)
|
||||
try {
|
||||
const reactions = await github.paginate(github.rest.reactions.listForIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const prAuthorId = pr.user.id;
|
||||
const uniqueThumbs = new Set(
|
||||
reactions
|
||||
.filter(r =>
|
||||
r.content === '+1' &&
|
||||
r.user &&
|
||||
r.user.id !== prAuthorId &&
|
||||
r.user.type !== 'Bot' &&
|
||||
!String(r.user.login || '').endsWith('[bot]')
|
||||
)
|
||||
.map(r => r.user.id)
|
||||
).size;
|
||||
|
||||
core.info(`Issue ${owner}/${repo}#${issue_number} has ${uniqueThumbs} unique 👍 reactions`);
|
||||
|
||||
const REQUIRED_THUMBS_UP = 5;
|
||||
if (uniqueThumbs < REQUIRED_THUMBS_UP) {
|
||||
core.setFailed(`Linked issue ${owner}/${repo}#${issue_number} has only ${uniqueThumbs} 👍 (need ${REQUIRED_THUMBS_UP}).`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const isSameRepo = owner === context.repo.owner && repo === context.repo.repo;
|
||||
if (error.status === 404 || error.status === 403) {
|
||||
if (!isSameRepo) {
|
||||
core.setFailed(
|
||||
`Linked issue ${owner}/${repo}#${issue_number} is not accessible. ` +
|
||||
`Please link to an issue in ${context.repo.owner}/${context.repo.repo} or a public repo.`
|
||||
);
|
||||
} else {
|
||||
core.info(`Cannot access reactions for ${owner}/${repo}#${issue_number}; skipping enforcement for same-repo issue.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other error should fail to prevent accidental bypass
|
||||
const msg = (error && error.message) ? String(error.message).toLowerCase() : '';
|
||||
const isRateLimit = msg.includes('rate limit') || error?.headers?.['x-ratelimit-remaining'] === '0';
|
||||
|
||||
if (isRateLimit) {
|
||||
core.setFailed(`Rate limit while checking reactions for ${owner}/${repo}#${issue_number}. Please retry the workflow.`);
|
||||
} else {
|
||||
core.setFailed(`Unexpected error checking reactions for ${owner}/${repo}#${issue_number}: ${error?.message || error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Check PR size
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to check (optional)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
size:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get PR data for manual trigger
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_number
|
||||
id: get_pr
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: ${{ github.event.inputs.pr_number }}
|
||||
});
|
||||
return JSON.stringify(data);
|
||||
|
||||
- name: Evaluate PR size
|
||||
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && github.event.inputs.pr_number)
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
PR_JSON: ${{ steps.get_pr.outputs.result }}
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request || JSON.parse(process.env.PR_JSON || '{}');
|
||||
if (!pr || !pr.number) {
|
||||
core.setFailed('Unable to resolve PR data. For workflow_dispatch, pass a valid pr_number.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for draft PRs and bots
|
||||
const isDraft = !!pr.draft;
|
||||
const login = pr.user.login;
|
||||
const isBot = pr.user.type === 'Bot' || /\[bot\]$/.test(login);
|
||||
|
||||
if (isDraft || isBot) {
|
||||
core.info('Draft or bot PR – skipping size enforcement');
|
||||
return;
|
||||
}
|
||||
|
||||
const totalChanges = pr.additions + pr.deletions;
|
||||
core.info(`PR contains ${pr.additions} additions and ${pr.deletions} deletions (${totalChanges} total)`);
|
||||
|
||||
const sizeLabel =
|
||||
totalChanges < 50 ? 'size/XS' :
|
||||
totalChanges < 150 ? 'size/S' :
|
||||
totalChanges < 600 ? 'size/M' :
|
||||
totalChanges < 1000 ? 'size/L' : 'size/XL';
|
||||
|
||||
// Re-fetch labels to avoid acting on stale payload data
|
||||
const { data: freshIssue } = await github.rest.issues.get({
|
||||
...context.repo,
|
||||
issue_number: pr.number
|
||||
});
|
||||
const currentLabels = (freshIssue.labels || []).map(l => l.name);
|
||||
|
||||
// Remove old size labels before adding new one
|
||||
const allSizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
|
||||
const toRemove = currentLabels.filter(name => allSizeLabels.includes(name) && name !== sizeLabel);
|
||||
|
||||
for (const name of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo,
|
||||
issue_number: pr.number,
|
||||
name
|
||||
});
|
||||
} catch (_) {
|
||||
// Ignore if already removed
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: pr.number,
|
||||
labels: [sizeLabel]
|
||||
});
|
||||
|
||||
// Check if PR author is a maintainer
|
||||
let authorPerm = 'none';
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: pr.user.login,
|
||||
});
|
||||
authorPerm = data.permission || 'none';
|
||||
} catch (_) {
|
||||
// User might not have any permissions
|
||||
}
|
||||
|
||||
core.info(`Author permission: ${authorPerm}`);
|
||||
const isMaintainer = ['admin', 'maintain'].includes(authorPerm); // Stricter maintainer definition
|
||||
|
||||
// Check for bypass label (using fresh labels)
|
||||
const hasBypass = currentLabels.includes('bypass:size-limit');
|
||||
|
||||
const MAX_LINES = 1000;
|
||||
if (totalChanges > MAX_LINES) {
|
||||
if (isMaintainer || hasBypass) {
|
||||
core.info(`${isMaintainer ? 'Maintainer' : 'Bypass label'} - allowing large PR with ${totalChanges} lines`);
|
||||
} else {
|
||||
core.setFailed(
|
||||
`This PR contains ${totalChanges} lines of changes, which exceeds the maximum of ${MAX_LINES} lines. ` +
|
||||
`Please split this into smaller, focused pull requests.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Check PR Up-to-Date
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-up-to-date:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip for bot PRs
|
||||
if: ${{ !contains(github.actor, '[bot]') }}
|
||||
concurrency:
|
||||
group: check-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2 # Sufficient for rev-list comparison
|
||||
|
||||
- name: Check if PR is up-to-date with main
|
||||
id: check
|
||||
run: |
|
||||
# Fetch the latest main branch
|
||||
git fetch origin main
|
||||
|
||||
# Check how many commits behind main
|
||||
BEHIND=$(git rev-list --count HEAD..origin/main)
|
||||
|
||||
echo "commits_behind=$BEHIND" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "$BEHIND" -gt 0 ]; then
|
||||
echo "::warning::PR is $BEHIND commits behind main"
|
||||
exit 0 # Don't fail the check, just warn
|
||||
else
|
||||
echo "PR is up-to-date with main"
|
||||
fi
|
||||
|
||||
- name: Comment if PR needs update
|
||||
if: ${{ steps.check.outputs.commits_behind != '0' }}
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const behind = ${{ steps.check.outputs.commits_behind }};
|
||||
const COMMENT_COOLDOWN_HOURS = 24;
|
||||
const COOLDOWN_MS = COMMENT_COOLDOWN_HOURS * 60 * 60 * 1000;
|
||||
|
||||
// Check for recent similar comments
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
per_page: 10
|
||||
});
|
||||
|
||||
const hasRecentComment = comments.some(c =>
|
||||
c.body?.includes('commits behind `main`') &&
|
||||
c.user?.login === 'github-actions[bot]' &&
|
||||
new Date(c.created_at) > new Date(Date.now() - COOLDOWN_MS)
|
||||
);
|
||||
|
||||
if (!hasRecentComment) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: `📊 **PR Status**: ${behind} commits behind \`main\`\n\nConsider updating your branch for the most accurate CI results:\n\n**Option 1**: Use GitHub's "Update branch" button (if available)\n\n**Option 2**: Update locally:\n\`\`\`bash\ngit fetch origin main\ngit merge origin/main\ngit push\n\`\`\`\n\n*Note: If you use a different remote name (e.g., upstream), adjust the commands accordingly.*\n\nThis ensures your changes are tested against the latest code.`
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "Fork PR number for secure live API testing"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
pr_head_sha:
|
||||
description: "Exact fork PR head SHA to test"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.inputs.pr_number || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Keep ordinary PR formatting and secure fork formatting on the same
|
||||
# pyink/isort versions. The regular format-check job installs lint-imports
|
||||
# separately because only that path runs the import-structure check.
|
||||
env:
|
||||
FORMATTER_PIP_PACKAGES: pyink==24.3.0 isort==5.13.2
|
||||
IMPORT_LINTER_PIP_PACKAGE: import-linter==2.11
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format-check:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install format tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ${FORMATTER_PIP_PACKAGES} ${IMPORT_LINTER_PIP_PACKAGE}
|
||||
|
||||
- name: Check formatting
|
||||
id: format-check
|
||||
env:
|
||||
GITHUB_TOKEN: ""
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pyink --check --diff .
|
||||
isort --check-only --diff .
|
||||
|
||||
- name: Check import structure
|
||||
id: import-check
|
||||
env:
|
||||
GITHUB_TOKEN: ""
|
||||
run: |
|
||||
set -euo pipefail
|
||||
lint-imports --config pyproject.toml
|
||||
|
||||
- name: Comment on PR if formatting fails
|
||||
if: failure() && steps.format-check.outcome == 'failure'
|
||||
uses: actions/github-script@v9
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.payload.pull_request.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '❌ **Formatting Check Failed**\n\nYour PR has formatting issues. Please run the following command locally and push the changes:\n\n```bash\n./autoformat.sh\n```\n\nThis will automatically fix all formatting issues using pyink (Google\'s Python formatter) and isort.'
|
||||
}).catch(err => {
|
||||
console.log('Comment posting failed:', err.message);
|
||||
});
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox
|
||||
pip install -e ".[dev,test]"
|
||||
|
||||
- name: Run unit tests and linting
|
||||
run: |
|
||||
PY_VERSION=$(echo "${{ matrix.python-version }}" | tr -d '.')
|
||||
# Format check is handled by separate job for better isolation
|
||||
tox -e py${PY_VERSION},lint-src,lint-tests
|
||||
|
||||
live-api-tests:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox
|
||||
pip install -e ".[dev,test]"
|
||||
|
||||
- name: Run live API tests
|
||||
env:
|
||||
GITHUB_TOKEN: ""
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z '${{ secrets.GEMINI_API_KEY }}' && -z '${{ secrets.OPENAI_API_KEY }}' ]]; then
|
||||
echo "::notice::Live API tests skipped - API keys not configured"
|
||||
exit 0
|
||||
fi
|
||||
GEMINI_API_KEY="${{ secrets.GEMINI_API_KEY }}" \
|
||||
LANGEXTRACT_API_KEY="${{ secrets.GEMINI_API_KEY }}" \
|
||||
OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY }}" \
|
||||
tox -e live-api
|
||||
|
||||
plugin-integration-test:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect provider-related changes
|
||||
id: provider-changes
|
||||
uses: tj-actions/changed-files@v47
|
||||
with:
|
||||
files: |
|
||||
langextract/providers/**
|
||||
langextract/factory.py
|
||||
langextract/inference.py
|
||||
tests/provider_plugin_test.py
|
||||
pyproject.toml
|
||||
.github/workflows/ci.yaml
|
||||
|
||||
- name: Skip if no provider changes
|
||||
if: steps.provider-changes.outputs.any_changed == 'false'
|
||||
run: |
|
||||
echo "No provider-related changes detected – skipping plugin integration test."
|
||||
exit 0
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox
|
||||
|
||||
- name: Run plugin smoke test
|
||||
run: tox -e plugin-smoke
|
||||
|
||||
- name: Run plugin integration test
|
||||
run: tox -e plugin-integration
|
||||
|
||||
ollama-integration-test:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect file changes
|
||||
id: changes
|
||||
uses: tj-actions/changed-files@v47
|
||||
with:
|
||||
files: |
|
||||
langextract/inference.py
|
||||
examples/ollama/**
|
||||
tests/test_ollama_integration.py
|
||||
.github/workflows/ci.yaml
|
||||
|
||||
- name: Skip if no Ollama changes
|
||||
if: steps.changes.outputs.any_changed == 'false'
|
||||
run: |
|
||||
echo "No Ollama-related changes detected – skipping job."
|
||||
exit 0
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Launch Ollama container
|
||||
run: |
|
||||
docker run -d --name ollama \
|
||||
-p 127.0.0.1:11434:11434 \
|
||||
-v ollama:/root/.ollama \
|
||||
ollama/ollama:0.5.4
|
||||
for i in {1..20}; do
|
||||
curl -fs http://localhost:11434/api/version && break
|
||||
sleep 3
|
||||
done
|
||||
|
||||
- name: Pull gemma2 model
|
||||
run: docker exec ollama ollama pull gemma2:2b || true
|
||||
|
||||
- name: Install tox
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox
|
||||
|
||||
- name: Run Ollama integration tests
|
||||
run: tox -e ollama-integration
|
||||
|
||||
test-fork-pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
name: live-keys
|
||||
# Triggered by maintainer label on a fork PR or secure manual dispatch from
|
||||
# main with an exact PR SHA.
|
||||
if: |
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'labeled' &&
|
||||
github.event.label.name == 'ready-to-merge' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
) || (
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
github.event.inputs.pr_number != '' &&
|
||||
github.event.inputs.pr_head_sha != ''
|
||||
)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Resolve secure PR metadata
|
||||
id: pr-metadata
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.actor
|
||||
});
|
||||
|
||||
const isMaintainer = ['admin', 'maintain'].includes(permission.permission);
|
||||
if (!isMaintainer) {
|
||||
throw new Error(`User ${context.actor} does not have maintainer permissions.`);
|
||||
}
|
||||
|
||||
let prNumber;
|
||||
let shaToTest;
|
||||
if (context.eventName === 'pull_request_target') {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
shaToTest = context.payload.pull_request.head.sha;
|
||||
} else {
|
||||
const dispatchInputs = context.payload.inputs ?? {};
|
||||
const prNumberInput = (dispatchInputs.pr_number || '').trim();
|
||||
shaToTest = (dispatchInputs.pr_head_sha || '').trim();
|
||||
if (!/^[1-9]\d*$/.test(prNumberInput)) {
|
||||
throw new Error(`Invalid pr_number input: ${dispatchInputs.pr_number}`);
|
||||
}
|
||||
prNumber = Number.parseInt(prNumberInput, 10);
|
||||
if (!/^[0-9a-f]{40}$/i.test(shaToTest)) {
|
||||
throw new Error(`Invalid pr_head_sha input: ${shaToTest}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { data: pullRequest } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
if (pullRequest.state !== 'open') {
|
||||
throw new Error(`PR #${prNumber} is ${pullRequest.state}, expected open.`);
|
||||
}
|
||||
|
||||
if (pullRequest.base.ref !== 'main') {
|
||||
throw new Error(
|
||||
`PR #${prNumber} targets ${pullRequest.base.ref}, expected main.`
|
||||
);
|
||||
}
|
||||
|
||||
const headRepoFullName = pullRequest.head.repo?.full_name;
|
||||
if (!headRepoFullName) {
|
||||
throw new Error(
|
||||
`PR #${prNumber} head repository is unavailable. ` +
|
||||
`The fork may have been deleted.`
|
||||
);
|
||||
}
|
||||
|
||||
// Repeat this check here because workflow_dispatch does not carry
|
||||
// the pull_request_target fork filter.
|
||||
if (headRepoFullName === `${context.repo.owner}/${context.repo.repo}`) {
|
||||
throw new Error(
|
||||
`PR #${prNumber} is from the base repository; use normal PR checks instead.`
|
||||
);
|
||||
}
|
||||
|
||||
if (context.eventName === 'workflow_dispatch' &&
|
||||
pullRequest.head.sha !== shaToTest) {
|
||||
throw new Error(
|
||||
`PR #${prNumber} head moved to ${pullRequest.head.sha}. ` +
|
||||
`Re-run workflow_dispatch with the latest SHA.`
|
||||
);
|
||||
}
|
||||
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
core.setOutput('sha_to_test', shaToTest);
|
||||
|
||||
- name: Pin commit SHA for security
|
||||
id: sha-pin
|
||||
env:
|
||||
STEPS_PR_METADATA_OUTPUTS_SHA_TO_TEST: ${{ steps.pr-metadata.outputs.sha_to_test }}
|
||||
run: |
|
||||
SHA_TO_TEST="${STEPS_PR_METADATA_OUTPUTS_SHA_TO_TEST}"
|
||||
echo "SHA_TO_TEST=${SHA_TO_TEST}" >> $GITHUB_OUTPUT
|
||||
echo "::notice title=Security::Pinned commit SHA for testing: ${SHA_TO_TEST}"
|
||||
|
||||
- name: Checkout base repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fetch and verify exact PR commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EXPECTED_SHA="${STEPS_SHA_PIN_OUTPUTS_SHA_TO_TEST}"
|
||||
echo "Fetching exact commit: $EXPECTED_SHA"
|
||||
|
||||
# Fetch the specific commit SHA
|
||||
git fetch --no-tags --prune --no-recurse-submodules origin "$EXPECTED_SHA" || {
|
||||
echo "::error::Failed to fetch PR commit $EXPECTED_SHA. The commit may have been deleted."
|
||||
exit 1
|
||||
}
|
||||
|
||||
git checkout -b pr-to-test "$EXPECTED_SHA"
|
||||
|
||||
# Verify checkout
|
||||
ACTUAL_SHA="$(git rev-parse HEAD)"
|
||||
if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
|
||||
echo "::error::SHA verification failed! Expected $EXPECTED_SHA but got $ACTUAL_SHA"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice title=Security::Successfully verified commit SHA: $ACTUAL_SHA"
|
||||
env:
|
||||
STEPS_SHA_PIN_OUTPUTS_SHA_TO_TEST: ${{ steps.sha-pin.outputs.SHA_TO_TEST }}
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install format tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ${FORMATTER_PIP_PACKAGES}
|
||||
|
||||
- name: Validate PR formatting
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Validating code formatting..."
|
||||
pyink --check --diff . || {
|
||||
echo "::error::Code formatting (pyink) does not meet project standards. Please run ./autoformat.sh locally and push the changes."
|
||||
exit 1
|
||||
}
|
||||
isort --check-only --diff . || {
|
||||
echo "::error::Import sorting (isort) does not meet project standards. Please run ./autoformat.sh locally and push the changes."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Merge verified PR commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
SHA_TO_MERGE="${STEPS_SHA_PIN_OUTPUTS_SHA_TO_TEST}"
|
||||
echo "Merging verified commit: $SHA_TO_MERGE"
|
||||
|
||||
git fetch --no-tags --prune --no-recurse-submodules origin "$SHA_TO_MERGE"
|
||||
git merge --no-ff --no-edit "$SHA_TO_MERGE" || {
|
||||
echo "::error::Failed to merge commit $SHA_TO_MERGE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "::notice title=Security::Successfully merged verified commit"
|
||||
env:
|
||||
STEPS_SHA_PIN_OUTPUTS_SHA_TO_TEST: ${{ steps.sha-pin.outputs.SHA_TO_TEST }}
|
||||
|
||||
- name: Add status comment
|
||||
uses: actions/github-script@v9
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr-metadata.outputs.pr_number }}
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: Number(process.env.PR_NUMBER),
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: 'Preparing to run live API tests (pending environment approval and API key availability)...'
|
||||
}).catch(err => {
|
||||
console.log('Comment posting failed:', err.message);
|
||||
});
|
||||
|
||||
- name: Run live API tests
|
||||
env:
|
||||
GITHUB_TOKEN: ""
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z '${{ secrets.GEMINI_API_KEY }}' && -z '${{ secrets.OPENAI_API_KEY }}' ]]; then
|
||||
echo "::notice::Live API tests skipped - API keys not configured"
|
||||
exit 0
|
||||
fi
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox
|
||||
pip install -e ".[dev,test]"
|
||||
GEMINI_API_KEY="${{ secrets.GEMINI_API_KEY }}" \
|
||||
LANGEXTRACT_API_KEY="${{ secrets.GEMINI_API_KEY }}" \
|
||||
OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY }}" \
|
||||
tox -e live-api
|
||||
|
||||
- name: Report success
|
||||
if: success()
|
||||
uses: actions/github-script@v9
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr-metadata.outputs.pr_number }}
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: Number(process.env.PR_NUMBER),
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '✅ Live API tests passed! All endpoints are working correctly.'
|
||||
}).catch(err => {
|
||||
console.log('Comment posting failed:', err.message);
|
||||
});
|
||||
|
||||
- name: Report failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v9
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr-metadata.outputs.pr_number }}
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: Number(process.env.PR_NUMBER),
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '❌ Live API tests failed. Please check the workflow logs for details.'
|
||||
}).catch(err => {
|
||||
console.log('Comment posting failed:', err.message);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Verify build artifacts
|
||||
run: |
|
||||
ls -la dist/
|
||||
pip install twine
|
||||
twine check dist/*
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
@@ -0,0 +1,157 @@
|
||||
name: Revalidate PR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to validate'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
checks: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
revalidate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get PR data
|
||||
id: pr_data
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: ${{ inputs.pr_number }}
|
||||
});
|
||||
|
||||
core.info(`Validating PR #${pr.number}: ${pr.title}`);
|
||||
core.info(`Author: ${pr.user.login}`);
|
||||
core.info(`Changes: +${pr.additions} -${pr.deletions}`);
|
||||
|
||||
// Store head SHA for creating status
|
||||
core.setOutput('head_sha', pr.head.sha);
|
||||
|
||||
return pr;
|
||||
|
||||
- name: Create pending status
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: '${{ steps.pr_data.outputs.head_sha }}',
|
||||
state: 'pending',
|
||||
context: 'Manual Validation',
|
||||
description: 'Running validation checks...'
|
||||
});
|
||||
|
||||
- name: Validate PR
|
||||
id: validate
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const pr = ${{ steps.pr_data.outputs.result }};
|
||||
const errors = [];
|
||||
let passed = true;
|
||||
|
||||
// Check size
|
||||
const totalChanges = pr.additions + pr.deletions;
|
||||
const MAX_LINES = 1000;
|
||||
if (totalChanges > MAX_LINES) {
|
||||
errors.push(`PR size (${totalChanges} lines) exceeds ${MAX_LINES} line limit`);
|
||||
passed = false;
|
||||
}
|
||||
|
||||
// Check template
|
||||
const body = pr.body || '';
|
||||
const requiredSections = ["# Description", "Fixes #", "# How Has This Been Tested?", "# Checklist"];
|
||||
const missingSections = requiredSections.filter(section => !body.includes(section));
|
||||
|
||||
if (missingSections.length > 0) {
|
||||
errors.push(`Missing PR template sections: ${missingSections.join(', ')}`);
|
||||
passed = false;
|
||||
}
|
||||
|
||||
if (body.match(/Replace this with|Choose one:|Fixes #\[issue number\]/i)) {
|
||||
errors.push('PR template contains unmodified placeholders');
|
||||
passed = false;
|
||||
}
|
||||
|
||||
// Check linked issue
|
||||
const issueMatch = body.match(/(?:Fixes|Closes|Resolves)\s+#(\d+)/i);
|
||||
if (!issueMatch) {
|
||||
errors.push('No linked issue found');
|
||||
passed = false;
|
||||
}
|
||||
|
||||
// Store results
|
||||
core.setOutput('passed', passed);
|
||||
core.setOutput('errors', errors.join('; '));
|
||||
core.setOutput('totalChanges', totalChanges);
|
||||
core.setOutput('hasTemplate', missingSections.length === 0);
|
||||
core.setOutput('hasIssue', !!issueMatch);
|
||||
|
||||
if (!passed) {
|
||||
core.setFailed(errors.join('; '));
|
||||
}
|
||||
|
||||
- name: Update commit status
|
||||
if: always()
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const passed = ${{ steps.validate.outputs.passed }};
|
||||
const errors = '${{ steps.validate.outputs.errors }}';
|
||||
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: '${{ steps.pr_data.outputs.head_sha }}',
|
||||
state: passed ? 'success' : 'failure',
|
||||
context: 'Manual Validation',
|
||||
description: passed ? 'All validation checks passed' : errors.substring(0, 140),
|
||||
target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
|
||||
});
|
||||
|
||||
- name: Add validation comment
|
||||
if: always()
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const pr = ${{ steps.pr_data.outputs.result }};
|
||||
const passed = ${{ steps.validate.outputs.passed }};
|
||||
const totalChanges = ${{ steps.validate.outputs.totalChanges }};
|
||||
const hasTemplate = ${{ steps.validate.outputs.hasTemplate }};
|
||||
const hasIssue = ${{ steps.validate.outputs.hasIssue }};
|
||||
const errors = '${{ steps.validate.outputs.errors }}'.split('; ').filter(e => e);
|
||||
|
||||
let body = `### Manual Validation Results\n\n`;
|
||||
body += `**Status**: ${passed ? '✅ Passed' : '❌ Failed'}\n\n`;
|
||||
body += `| Check | Status | Details |\n`;
|
||||
body += `|-------|--------|----------|\n`;
|
||||
body += `| PR Size | ${totalChanges <= 1000 ? '✅' : '❌'} | ${totalChanges} lines ${totalChanges > 1000 ? '(exceeds 1000 limit)' : ''} |\n`;
|
||||
body += `| Template | ${hasTemplate ? '✅' : '❌'} | ${hasTemplate ? 'Complete' : 'Missing required sections'} |\n`;
|
||||
body += `| Linked Issue | ${hasIssue ? '✅' : '❌'} | ${hasIssue ? 'Found' : 'Missing Fixes/Closes #XXX'} |\n`;
|
||||
|
||||
if (errors.length > 0) {
|
||||
body += `\n**Errors:**\n`;
|
||||
errors.forEach(error => {
|
||||
body += `- ❌ ${error}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
body += `\n[View workflow run](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: body
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Validate Community Providers
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'COMMUNITY_PROVIDERS.md'
|
||||
- 'scripts/validate_community_providers.py'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Validate table format
|
||||
run: |
|
||||
python scripts/validate_community_providers.py COMMUNITY_PROVIDERS.md
|
||||
@@ -0,0 +1,128 @@
|
||||
name: Validate PR template
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check PR author permissions
|
||||
id: check
|
||||
if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const {owner, repo} = context.repo;
|
||||
const actor = pr.user.login;
|
||||
const authorType = pr.user.type;
|
||||
|
||||
// Check if PR author is a bot (e.g., Dependabot)
|
||||
if (authorType === 'Bot') {
|
||||
core.setOutput('skip_validation', 'true');
|
||||
console.log(`Skipping validation for bot-authored PR: ${actor}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a community provider PR (only modifies COMMUNITY_PROVIDERS.md)
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner, repo,
|
||||
pull_number: pr.number
|
||||
});
|
||||
|
||||
const isCommunityProviderPR = files.length === 1 &&
|
||||
files[0].filename === 'COMMUNITY_PROVIDERS.md';
|
||||
|
||||
if (isCommunityProviderPR) {
|
||||
core.setOutput('is_community_provider', 'true');
|
||||
console.log('Community provider PR detected - relaxed validation will apply');
|
||||
} else {
|
||||
core.setOutput('is_community_provider', 'false');
|
||||
}
|
||||
|
||||
// Get permission level
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner, repo, username: actor
|
||||
});
|
||||
|
||||
const permission = data.permission; // admin|maintain|write|triage|read|none
|
||||
console.log(`Actor ${actor} has permission level: ${permission}`);
|
||||
|
||||
// Check if user has write+ permissions
|
||||
if (['admin', 'maintain', 'write'].includes(permission)) {
|
||||
core.setOutput('skip_validation', 'true');
|
||||
console.log(`Skipping validation for maintainer: ${actor} (${permission})`);
|
||||
} else {
|
||||
core.setOutput('skip_validation', 'false');
|
||||
console.log(`Validation required for: ${actor} (${permission})`);
|
||||
}
|
||||
} catch (e) {
|
||||
// If we can't determine permissions, require validation
|
||||
core.setOutput('skip_validation', 'false');
|
||||
core.warning(`Permission lookup failed: ${e.message}`);
|
||||
}
|
||||
|
||||
- name: Validate PR template
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.draft == false &&
|
||||
steps.check.outputs.skip_validation != 'true'
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
IS_COMMUNITY_PROVIDER: ${{ steps.check.outputs.is_community_provider }}
|
||||
run: |
|
||||
printf '%s\n' "$PR_BODY" | tr -d '\r' > body.txt
|
||||
|
||||
# Required sections from the template
|
||||
required=( "# Description" "# How Has This Been Tested?" "# Checklist" )
|
||||
err=0
|
||||
|
||||
# Check for required sections
|
||||
for h in "${required[@]}"; do
|
||||
grep -Fq "$h" body.txt || { echo "::error::$h missing"; err=1; }
|
||||
done
|
||||
|
||||
# Check for issue reference - relaxed for community provider PRs
|
||||
if [ "$IS_COMMUNITY_PROVIDER" = "true" ]; then
|
||||
# For community provider PRs, accept either "Fixes #" or "Related to #" (case-insensitive)
|
||||
if ! grep -Eiq '(Fixes #[0-9]+|Related to #[0-9]+)' body.txt; then
|
||||
echo "::error::Issue reference missing (need 'Fixes #NNN' or 'Related to #NNN')"
|
||||
err=1
|
||||
fi
|
||||
else
|
||||
# For other PRs, require "Fixes #" with a number
|
||||
if ! grep -Eq 'Fixes #[0-9]+' body.txt; then
|
||||
echo "::error::Missing 'Fixes #NNN' reference"
|
||||
err=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for placeholder text that should be replaced
|
||||
grep -Eiq 'Replace this with|Choose one:' body.txt && {
|
||||
echo "::error::Template placeholders still present"; err=1;
|
||||
}
|
||||
|
||||
# Also check for the unmodified issue number placeholder
|
||||
grep -Fq 'Fixes #[issue number]' body.txt && {
|
||||
echo "::error::Issue number placeholder not updated"; err=1;
|
||||
}
|
||||
|
||||
exit $err
|
||||
|
||||
- name: Log skip reason
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' &&
|
||||
(github.event.pull_request.draft == true ||
|
||||
steps.check.outputs.skip_validation == 'true')
|
||||
run: |
|
||||
echo "Skipping PR template validation. Draft: ${{ github.event.pull_request.draft }}; skip_validation: ${{ steps.check.outputs.skip_validation || 'N/A' }}"
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright 2025 Google LLC.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Publish to Zenodo
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Tag to publish (e.g. v1.3.0). Used only when manually re-running.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: zenodo-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
zenodo:
|
||||
# Only run on releases from the main repository, not forks
|
||||
# Skip pre-releases to avoid creating DOIs for test releases
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (!github.event.release.prerelease && github.repository == 'google/langextract') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
ZENODO_TOKEN: ${{ secrets.ZENODO_TOKEN }}
|
||||
ZENODO_RECORD_ID: ${{ secrets.ZENODO_RECORD_ID }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.release_tag || github.ref_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
steps:
|
||||
# Note: we deliberately do not check out the input ref. workflow_dispatch
|
||||
# is meant for diagnostic re-runs of the latest release, and we want the
|
||||
# *fixed* script from main, not whatever script existed at the older tag.
|
||||
# The build below uses main's pyproject.toml; this only works correctly
|
||||
# when main's version still matches the tag being republished.
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Build distributions
|
||||
run: |
|
||||
python -m pip install --upgrade pip build
|
||||
python -m build
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install requests
|
||||
|
||||
- name: Publish new Zenodo version
|
||||
run: python .github/scripts/zenodo_publish.py
|
||||
Reference in New Issue
Block a user