chore: automate OSS weekend mode
This commit is contained in:
220
.github/scripts/oss-weekend.mjs
vendored
Normal file
220
.github/scripts/oss-weekend.mjs
vendored
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const TIME_ZONE = "Europe/Berlin";
|
||||||
|
const README_PATH = "README.md";
|
||||||
|
const MARKER_START = "<!-- OSS_WEEKEND_START -->";
|
||||||
|
const MARKER_END = "<!-- OSS_WEEKEND_END -->";
|
||||||
|
const DISCORD_URL = "https://discord.com/invite/3cU7Bz4UPx";
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {};
|
||||||
|
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (!arg.startsWith("--")) continue;
|
||||||
|
|
||||||
|
const trimmedArg = arg.slice(2);
|
||||||
|
const separatorIndex = trimmedArg.indexOf("=");
|
||||||
|
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
options[trimmedArg] = "true";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmedArg.slice(0, separatorIndex);
|
||||||
|
const value = trimmedArg.slice(separatorIndex + 1);
|
||||||
|
options[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOption(name, cliOptions, envName, fallback) {
|
||||||
|
const cliValue = cliOptions[name];
|
||||||
|
if (cliValue !== undefined) return cliValue;
|
||||||
|
|
||||||
|
const envValue = process.env[envName];
|
||||||
|
if (envValue !== undefined && envValue !== "") return envValue;
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthy(value) {
|
||||||
|
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBerlinParts(date) {
|
||||||
|
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: TIME_ZONE,
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const values = {};
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === "literal") continue;
|
||||||
|
values[part.type] = part.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekday: values.weekday,
|
||||||
|
year: Number(values.year),
|
||||||
|
month: Number(values.month),
|
||||||
|
day: Number(values.day),
|
||||||
|
hour: Number(values.hour),
|
||||||
|
minute: Number(values.minute),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLongDate(date) {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: TIME_ZONE,
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function determineAction(mode, now) {
|
||||||
|
if (mode === "close" || mode === "open") return mode;
|
||||||
|
if (mode !== "auto") {
|
||||||
|
throw new Error(`Unsupported mode: ${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const berlinNow = getBerlinParts(now);
|
||||||
|
|
||||||
|
if (berlinNow.weekday === "Fri" && berlinNow.hour === 17 && berlinNow.minute === 0) {
|
||||||
|
return "close";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (berlinNow.weekday === "Mon" && berlinNow.hour === 0 && berlinNow.minute === 5) {
|
||||||
|
return "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBanner(now) {
|
||||||
|
const startDate = formatLongDate(now);
|
||||||
|
const reopenDate = formatLongDate(addDays(now, 3));
|
||||||
|
|
||||||
|
return [
|
||||||
|
MARKER_START,
|
||||||
|
"# 🏖️ OSS Weekend",
|
||||||
|
"",
|
||||||
|
`**Issue tracker reopens ${reopenDate}.**`,
|
||||||
|
"",
|
||||||
|
`OSS weekend runs ${startDate} through ${reopenDate}. For support, join [Discord](${DISCORD_URL}).`,
|
||||||
|
MARKER_END,
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertBanner(readme, now) {
|
||||||
|
const banner = buildBanner(now);
|
||||||
|
const bannerPattern = new RegExp(
|
||||||
|
`${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bannerPattern.test(readme)) {
|
||||||
|
return readme.replace(bannerPattern, banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${banner}${readme}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBanner(readme) {
|
||||||
|
const bannerPattern = new RegExp(
|
||||||
|
`^${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n\\n---\\n\\n?`,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
|
||||||
|
return readme.replace(bannerPattern, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeGithubOutput(output) {
|
||||||
|
const githubOutputPath = process.env.GITHUB_OUTPUT;
|
||||||
|
if (!githubOutputPath) return;
|
||||||
|
|
||||||
|
const lines = Object.entries(output).map(([key, value]) => `${key}=${value}`);
|
||||||
|
await writeFile(githubOutputPath, `${lines.join("\n")}\n`, { flag: "a" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cliOptions = parseArgs(process.argv.slice(2));
|
||||||
|
const mode = getOption("mode", cliOptions, "OSS_WEEKEND_MODE", "auto");
|
||||||
|
const dryRun = isTruthy(getOption("dry-run", cliOptions, "OSS_WEEKEND_DRY_RUN", "false"));
|
||||||
|
const nowInput = getOption("now", cliOptions, "OSS_WEEKEND_NOW", "");
|
||||||
|
const readmePath = getOption("readme", cliOptions, "OSS_WEEKEND_README_PATH", README_PATH);
|
||||||
|
|
||||||
|
const now = nowInput ? new Date(nowInput) : new Date();
|
||||||
|
if (Number.isNaN(now.getTime())) {
|
||||||
|
throw new Error(`Invalid date: ${nowInput}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = determineAction(mode, now);
|
||||||
|
const currentReadme = await readFile(readmePath, "utf8");
|
||||||
|
|
||||||
|
let nextReadme = currentReadme;
|
||||||
|
if (action === "close") nextReadme = upsertBanner(currentReadme, now);
|
||||||
|
if (action === "open") nextReadme = removeBanner(currentReadme);
|
||||||
|
|
||||||
|
const readmeChanged = nextReadme !== currentReadme;
|
||||||
|
|
||||||
|
if (readmeChanged && !dryRun) {
|
||||||
|
await writeFile(readmePath, nextReadme, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
action,
|
||||||
|
dry_run: dryRun ? "true" : "false",
|
||||||
|
readme_path: readmePath,
|
||||||
|
readme_changed: readmeChanged ? "true" : "false",
|
||||||
|
issue_state: action === "close" ? "disabled" : action === "open" ? "enabled" : "unchanged",
|
||||||
|
commit_message:
|
||||||
|
action === "close"
|
||||||
|
? "docs: enable OSS Weekend notice"
|
||||||
|
: action === "open"
|
||||||
|
? "docs: disable OSS Weekend notice"
|
||||||
|
: "",
|
||||||
|
now_utc: now.toISOString(),
|
||||||
|
now_berlin: new Intl.DateTimeFormat("sv-SE", {
|
||||||
|
timeZone: TIME_ZONE,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).format(now),
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeGithubOutput(output);
|
||||||
|
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
88
.github/workflows/oss-weekend.yml
vendored
Normal file
88
.github/workflows/oss-weekend.yml
vendored
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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 }}"
|
||||||
Reference in New Issue
Block a user