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
+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