From 78d184447e5dad444a531920a73fc631fa6c77f4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 14 Mar 2026 16:12:28 +0100 Subject: [PATCH] chore: automate OSS weekend mode --- .github/scripts/oss-weekend.mjs | 220 ++++++++++++++++++++++++++++++ .github/workflows/oss-weekend.yml | 88 ++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 .github/scripts/oss-weekend.mjs create mode 100644 .github/workflows/oss-weekend.yml diff --git a/.github/scripts/oss-weekend.mjs b/.github/scripts/oss-weekend.mjs new file mode 100644 index 00000000..a72b757d --- /dev/null +++ b/.github/scripts/oss-weekend.mjs @@ -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 = ""; +const MARKER_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); +}); diff --git a/.github/workflows/oss-weekend.yml b/.github/workflows/oss-weekend.yml new file mode 100644 index 00000000..e3432d41 --- /dev/null +++ b/.github/workflows/oss-weekend.yml @@ -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 }}"