chore: add OSS weekend gating

This commit is contained in:
Mario Zechner
2026-03-14 16:28:02 +01:00
parent 78d184447e
commit 572876be1e
5 changed files with 437 additions and 346 deletions

View File

@@ -0,0 +1,98 @@
name: OSS Weekend Issues
on:
issues:
types: [opened]
jobs:
close-issues-during-weekend:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Close new issues during OSS weekend
uses: actions/github-script@v7
with:
script: |
const issueAuthor = context.payload.issue.user.login;
const defaultBranch = context.payload.repository.default_branch;
if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') {
console.log(`Skipping bot: ${issueAuthor}`);
return;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
const permission = await getPermission(issueAuthor);
if (['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${issueAuthor} is a collaborator with ${permission} access`);
return;
}
let weekendState;
try {
weekendState = JSON.parse(await getTextFile('.github/oss-weekend.json'));
} catch (error) {
if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
console.log('OSS weekend is not active');
return;
}
throw error;
}
if (!weekendState?.active) {
console.log('OSS weekend is not active');
return;
}
const reopenDate = weekendState.reopensOnText || weekendState.reopensOn || 'after the weekend';
const discordUrl = weekendState.discordUrl || 'https://discord.com/invite/3cU7Bz4UPx';
const message = [
`Hi @${issueAuthor}, thanks for opening an issue.`,
'',
`OSS weekend is active until ${reopenDate}, so new issues are being auto-closed for now.`,
'',
`Please reopen or submit this issue again after ${reopenDate}. For support, join [Discord](${discordUrl}).`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
});

View File

@@ -1,88 +0,0 @@
name: OSS Weekend
on:
schedule:
- cron: "0 15,16 * * 5"
- cron: "5 22,23 * * 0"
workflow_dispatch:
inputs:
mode:
description: "close, open, or auto"
required: false
default: auto
type: choice
options:
- auto
- close
- open
dry_run:
description: "Preview changes without toggling issues or pushing README updates"
required: false
default: true
type: boolean
now:
description: "Optional ISO timestamp override for testing, e.g. 2026-03-20T16:00:00Z"
required: false
type: string
jobs:
oss-weekend:
runs-on: ubuntu-latest
environment: oss-weekend
permissions:
contents: write
env:
WORKFLOW_TOKEN: ${{ secrets.OSS_WEEKEND_TOKEN != '' && secrets.OSS_WEEKEND_TOKEN || github.token }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.OSS_WEEKEND_TOKEN != '' && secrets.OSS_WEEKEND_TOKEN || github.token }}
- name: Plan OSS weekend action
id: plan
env:
OSS_WEEKEND_MODE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.mode || 'auto' }}
OSS_WEEKEND_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run || 'false' }}
OSS_WEEKEND_NOW: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.now || '' }}
run: node .github/scripts/oss-weekend.mjs
- name: Stop when no action is due
if: steps.plan.outputs.action == 'none'
run: echo "No OSS weekend action due at this time"
- name: Require OSS_WEEKEND_TOKEN for live runs
if: steps.plan.outputs.action != 'none' && steps.plan.outputs.dry_run != 'true' && secrets.OSS_WEEKEND_TOKEN == ''
run: |
echo "OSS_WEEKEND_TOKEN is required for live OSS weekend runs"
exit 1
- name: Toggle issue tracker
if: steps.plan.outputs.action != 'none' && steps.plan.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ env.WORKFLOW_TOKEN }}
run: |
if [ "${{ steps.plan.outputs.action }}" = "close" ]; then
gh api --method PATCH repos/${{ github.repository }} -f has_issues=false
else
gh api --method PATCH repos/${{ github.repository }} -f has_issues=true
fi
- name: Commit README update
if: steps.plan.outputs.readme_changed == 'true' && steps.plan.outputs.dry_run != 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md
git diff --staged --quiet || git commit -m "${{ steps.plan.outputs.commit_message }}"
git push
- name: Summary
run: |
echo "action=${{ steps.plan.outputs.action }}"
echo "dry_run=${{ steps.plan.outputs.dry_run }}"
echo "readme_changed=${{ steps.plan.outputs.readme_changed }}"
echo "issue_state=${{ steps.plan.outputs.issue_state }}"
echo "now_utc=${{ steps.plan.outputs.now_utc }}"
echo "now_berlin=${{ steps.plan.outputs.now_berlin }}"

View File

@@ -19,47 +19,101 @@ jobs:
const prAuthor = context.payload.pull_request.user.login;
const defaultBranch = context.payload.repository.default_branch;
// Skip bots
if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') {
console.log(`Skipping bot: ${prAuthor}`);
return;
}
// Check if user is a collaborator (has write access)
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: prAuthor
});
if (['admin', 'write'].includes(permissionLevel.permission)) {
console.log(`${prAuthor} is a collaborator with ${permissionLevel.permission} access`);
return;
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
} catch (e) {
// User is not a collaborator, continue with check
}
// Fetch approved contributors list
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/APPROVED_CONTRIBUTORS',
ref: defaultBranch
});
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
const content = Buffer.from(fileContent.content, 'base64').toString('utf8');
const approvedList = content
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
async function closePullRequest(message) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: message,
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed',
});
}
const permission = await getPermission(prAuthor);
if (['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${prAuthor} is a collaborator with ${permission} access`);
return;
}
const approvedContent = await getTextFile('.github/APPROVED_CONTRIBUTORS');
const approvedList = approvedContent
.split('\n')
.map(line => line.trim().toLowerCase())
.filter(line => line && !line.startsWith('#'));
const isApprovedContributor = approvedList.includes(prAuthor.toLowerCase());
if (approvedList.includes(prAuthor.toLowerCase())) {
let weekendState = null;
try {
weekendState = JSON.parse(await getTextFile('.github/oss-weekend.json'));
} catch (error) {
if (!(error && typeof error === 'object' && 'status' in error && error.status === 404)) {
throw error;
}
}
if (weekendState?.active && isApprovedContributor) {
console.log(`${prAuthor} is approved, but OSS weekend is active`);
const reopenDate = weekendState.reopensOnText || weekendState.reopensOn || 'after the weekend';
const discordUrl = weekendState.discordUrl || 'https://discord.com/invite/3cU7Bz4UPx';
const message = [
`Hi @${prAuthor}, thanks for the PR.`,
'',
`OSS weekend is active until ${reopenDate}, so external PRs are being paused for now.`,
'',
'You are already on the approved contributors list, so you can resubmit this PR after the weekend without reapproval.',
'',
`This PR will be closed automatically. For support, join [Discord](${discordUrl}).`,
].join('\n');
await closePullRequest(message);
return;
}
if (isApprovedContributor) {
console.log(`${prAuthor} is in the approved contributors list`);
return;
}
// Not approved - close PR with comment
console.log(`${prAuthor} is not approved, closing PR`);
const message = [
@@ -72,19 +126,7 @@ jobs:
'2. Once a maintainer approves with `lgtm`, you\'ll be added to the approved contributors list',
'3. Then you can submit your PR',
'',
`This PR will be closed automatically. See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for more details.`
`This PR will be closed automatically. See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for more details.`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: message
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed'
});
await closePullRequest(message);