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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:14 +08:00
commit 76d991c447
147 changed files with 43242 additions and 0 deletions
+33
View File
@@ -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?
+27
View File
@@ -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.
+39
View File
@@ -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]"
+55
View File
@@ -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!"
+42
View File
@@ -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"
+217
View File
@@ -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())
+166
View File
@@ -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');
}
+188
View File
@@ -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}`);
}
}
+130
View File
@@ -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.`
});
}
+536
View File
@@ -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);
});
+55
View File
@@ -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
+157
View File
@@ -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
+128
View File
@@ -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' }}"
+66
View File
@@ -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