Push check: Stagger the scheduled runs so the scraper repos never overlap Every scraper repo ran its daily and Monday checks at 06:00 UTC. On Monday that is the full README matrix in all four at once, up to about sixteen jobs against one account, which is the burst that returned "Your system is sending too many of this type of request" during the LinkedIn Python build. Valid inputs fail with that error, so the runs would go red with nothing wrong in the code. The Monday runs now start at 06:00, 06:20, ... #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # The only workflow that calls the real API. Needs the BRIGHTDATA_API_TOKEN | |
| # secret and spends about one credit per profile. | |
| # | |
| # Four ways it runs: | |
| # on every push to main: one profile and the README's links, one | |
| # credit and about a minute, so a broken commit shows up at once. | |
| # A newer push cancels a run still going for an older commit. | |
| # by hand, from the Actions tab, to scrape whatever accounts you choose | |
| # every day at 06:20 UTC except Monday: one profile, the async | |
| # path, and the field table, about two credits | |
| # every Monday at 06:20 UTC: the same, plus every code block in the README, | |
| # about 35 credits and 30 minutes | |
| # | |
| # A slow day at the API shows up as a red run. Rerun it before reading more | |
| # into it. | |
| # | |
| # The daily and Monday runs end by rewriting the "last verified" badge line at | |
| # the top of README.md with today's date, and committing it if it changed. Push | |
| # runs never do this, so your own push is never followed by a bot commit. Only | |
| # the main repository commits; a copy of the repository just runs the checks. | |
| name: Live check | |
| run-name: "${{ github.event.schedule == '20 6 * * 1' && 'Weekly check: does every README block still work?' || github.event_name == 'schedule' && 'Daily check: is anything broken?' || github.event_name == 'push' && format('Push check: {0}', github.event.head_commit.message) || format('Scrape {0}', inputs.profiles) }}" | |
| on: | |
| push: | |
| branches: [main] | |
| schedule: | |
| - cron: "20 6 * * 0,2-6" # daily smoke, every day except Monday | |
| - cron: "20 6 * * 1" # Monday: the whole README. Staggered across the | |
| # scraper repos (06:00, 06:20, 06:40, 07:00) so four full matrices never | |
| # hit one account at once, which is what trips "too many requests". | |
| # The two `== '20 6 * * 1'` checks below must match this line exactly. | |
| workflow_dispatch: | |
| inputs: | |
| profiles: | |
| description: LinkedIn profile slugs or URLs, separated by spaces. | |
| default: satyanadella reidhoffman | |
| validate: | |
| description: Also run every README block, as the Monday run does | |
| type: boolean | |
| default: false | |
| only: | |
| description: Block numbers to run, comma separated, for a cheap check. Empty means all. | |
| default: "" | |
| permissions: | |
| contents: write # the daily check commits the regenerated field table | |
| concurrency: | |
| group: live-${{ github.event_name }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'push' }} # only the newest commit matters | |
| jobs: | |
| scrape: | |
| name: Fetch the profiles and attach them to this run | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 # fetch, async path, table; the README blocks have their own jobs | |
| env: | |
| BRIGHTDATA_API_TOKEN: ${{ secrets.BRIGHTDATA_API_TOKEN }} | |
| # On the daily schedule there are no inputs, so fall back to one cheap profile. | |
| PROFILES: ${{ inputs.profiles || 'satyanadella' }} | |
| # the async path and the field table run on every scheduled run and on request | |
| CHECK: ${{ github.event_name == 'schedule' || inputs.validate }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: "20" | |
| - name: Install the package and the Bright Data CLI | |
| run: | | |
| npm install | |
| npm i -g @brightdata/cli@0.3.4 # pinned: this job holds the API secret | |
| - name: Do the README's links still resolve? | |
| run: | | |
| public=$(curl -s -o /dev/null -w '%{http_code}' https://github.com/brightdata/linkedin-scraper-node) | |
| failed=0 | |
| for url in $(grep -ohE 'https?://[^ )>"`]+' README.md AGENTS.md | sed 's/[.,]$//' | sort -u); do | |
| case "$url" in | |
| *linkedin.com/*) continue ;; # answers 999 to anything without a session; the weekly README run fetches these profiles through the API anyway | |
| *zendesk.com/*) continue ;; # answers 403 to anything that is not a browser | |
| *mcp.brightdata.com/*) continue ;; # carries a placeholder token; nothing to check without a real one | |
| *github.com/brightdata/linkedin-scraper-node*) [ "$public" = "200" ] || continue ;; # private for now | |
| esac | |
| code=$(curl -s -o /dev/null -L --retry 3 --max-time 30 -A "Mozilla/5.0" -w '%{http_code}' "$url" || echo 000) | |
| if [ "$code" = "200" ]; then echo "200 $url"; else echo "::error::$code $url"; failed=1; fi | |
| done | |
| [ "$failed" = 0 ] || exit 1 | |
| - name: Fetch the profiles from LinkedIn | |
| run: | | |
| test -n "$BRIGHTDATA_API_TOKEN" || { | |
| echo "No API token. Add BRIGHTDATA_API_TOKEN under" >&2 | |
| echo "Settings, Secrets and variables, Actions, then start this again." >&2 | |
| exit 1 | |
| } | |
| # One retry, only on an API timeout. A slow day is not a broken build. | |
| set +e | |
| node src/cli.js $PROFILES 2>&1 | tee run.log | |
| code=${PIPESTATUS[0]} | |
| if [ "$code" != "0" ] && grep -q "timeout" run.log; then | |
| echo "::warning::The API timed out. Waiting 30s and trying once more." | |
| sleep 30 | |
| node src/cli.js $PROFILES 2>&1 | tee run.log | |
| code=${PIPESTATUS[0]} | |
| fi | |
| exit "$code" | |
| - name: Show what was found | |
| if: always() | |
| run: | | |
| set -o pipefail | |
| test -f linkedin.json || exit 0 | |
| node - <<'JS' | tee -a "$GITHUB_STEP_SUMMARY" | |
| import { readFileSync } from "node:fs"; | |
| const doc = JSON.parse(readFileSync("linkedin.json", "utf8")); | |
| console.log(`Run at ${doc.generated_at}\n`); | |
| console.log("| slug | fields | followers | current company |"); | |
| console.log("| --- | --- | --- | --- |"); | |
| for (const entry of doc.profiles) { | |
| const p = entry.profile; | |
| const fields = p ? Object.keys(p).length : 0; | |
| console.log( | |
| `| ${entry.slug} | ${fields} | ${p?.followers ?? "-"} | ${p?.current_company_name ?? "-"} |`, | |
| ); | |
| } | |
| const first = doc.profiles.map((e) => e.profile).filter(Boolean)[0]; | |
| if (first) { | |
| console.log("\n<details><summary>First record in full</summary>\n"); | |
| console.log("```json"); | |
| console.log(JSON.stringify(first, null, 2)); | |
| console.log("```\n"); | |
| console.log("</details>"); | |
| } | |
| JS | |
| # The two daily checks. Discovery is already covered by the step above. | |
| - name: Does the async trigger, status, fetch path still work? | |
| if: env.CHECK == 'true' | |
| run: | | |
| set -o pipefail | |
| node - <<'JS' | tee -a "$GITHUB_STEP_SUMMARY" | |
| import assert from "node:assert/strict"; | |
| import { bdclient } from "@brightdata/sdk"; | |
| const url = "https://www.linkedin.com/in/satyanadella/"; | |
| const client = new bdclient({ autoCreateZones: false }); | |
| try { | |
| const job = await client.scrape.linkedin.collectProfiles([url], { | |
| async: true, | |
| includeErrors: true, | |
| }); | |
| await job.wait({ pollInterval: 5_000, pollTimeout: 480_000 }); | |
| const status = await job.status(); | |
| assert.equal(status, "ready", `snapshot ${job.snapshotId} ended as ${status}`); | |
| const fetched = await job.fetch(); | |
| const rows = Array.isArray(fetched) ? fetched : [fetched]; | |
| assert.ok(rows.length && rows[0].url, "fetch returned no record"); | |
| console.log(`\nAsync path ok: trigger, status, fetch returned ${rows.length} record(s).`); | |
| } finally { | |
| await client.close(); | |
| } | |
| JS | |
| - name: Regenerate the README field table from the live schema | |
| if: always() && env.CHECK == 'true' && github.repository == 'brightdata/linkedin-scraper-node' | |
| run: | | |
| set -o pipefail | |
| node - <<'JS' | tee -a "$GITHUB_STEP_SUMMARY" | |
| import { readFileSync, writeFileSync } from "node:fs"; | |
| import assert from "node:assert/strict"; | |
| import { bdclient } from "@brightdata/sdk"; | |
| const path = "README.md"; | |
| const text = readFileSync(path, "utf8"); | |
| const start = "<!-- fields:start -->"; | |
| const end = "<!-- fields:end -->"; | |
| assert.ok(text.includes(start) && text.includes(end), "README field-table markers are missing"); | |
| const client = new bdclient({ autoCreateZones: false }); | |
| let fields; | |
| try { | |
| ({ fields } = await client.datasets.linkedinProfiles.getMetadata()); | |
| } finally { | |
| await client.close(); | |
| } | |
| const sample = JSON.parse(readFileSync("examples/sample_output.json", "utf8")) | |
| .profiles[0].profile; | |
| // fields is an object keyed by field name, whatever the SDK's .d.ts says | |
| // it is (DatasetField[]). Iterating it as an array yields nothing. | |
| const entries = Object.entries(fields); | |
| // The sample carries a subset of the schema, and the API also returns | |
| // fields the schema never lists, so these two counts differ. | |
| const known = entries.filter(([n]) => n in sample).length; | |
| const extra = Object.keys(sample).filter((k) => !(k in fields)); | |
| const rows = ["| field | type | description |", "| --- | --- | --- |"]; | |
| for (const [name, spec] of entries) { | |
| const desc = (spec.description ?? "") | |
| .replaceAll(" — ", ": ") | |
| .replaceAll("—", ":") | |
| .split(/\s+/) | |
| .join(" ") | |
| .replaceAll("`", "'") // no fences: a description must not become a command | |
| .replaceAll("|", "/"); // no table breaks | |
| // The API marks personal data itself. Carry that through rather | |
| // than keeping a hand-written list that drifts from the schema. | |
| const pii = spec.pii ? "Personal data. " : ""; | |
| rows.push(`| \`${name}\` | ${spec.type} | ${pii}${desc} |`); | |
| } | |
| const block = [ | |
| start, | |
| "<details>", | |
| `<summary>All ${entries.length} fields, with type and description</summary>`, | |
| "", | |
| "Regenerated every day from the dataset schema, via", | |
| "`client.datasets.linkedinProfiles.getMetadata()`, so it cannot go stale. A", | |
| `profile carries the fields that apply to it: the sample file has ${known}`, | |
| `of these ${entries.length}${extra.length ? `, plus ${extra.map((k) => `\`${k}\``).join(" and ")},` : "."}`, | |
| ...(extra.length ? ["which the schema does not list."] : []), | |
| "", | |
| ...rows, | |
| "", | |
| "</details>", | |
| end, | |
| ].join("\n"); | |
| const pattern = new RegExp(`${start.replace(/[-[\]{}()*+?.,\\^$|#]/g, "\\$&")}[\\s\\S]*?${end.replace(/[-[\]{}()*+?.,\\^$|#]/g, "\\$&")}`); | |
| const next = text.replace(pattern, () => block); | |
| writeFileSync(path, next, "utf8"); | |
| console.log(`\nSchema: ${entries.length} fields. README table ${next === text ? "already current" : "updated"}.`); | |
| JS | |
| if git diff --quiet README.md; then | |
| echo "Nothing to commit." | |
| exit 0 | |
| fi | |
| # Never commit a README the tests would reject. | |
| npm test | |
| git config user.name "anil-bd" | |
| git config user.email "210667061+anil-bd@users.noreply.github.com" | |
| git add README.md | |
| git commit -m "Regenerate the field table from the live schema (automated daily check)" | |
| git pull --rebase origin main | |
| git push | |
| - name: Attach linkedin.json so you can download it | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| if: always() | |
| with: | |
| name: linkedin-json | |
| path: linkedin.json | |
| if-no-files-found: warn | |
| # Every code block in the README, each in its own job, all at once. Mondays, | |
| # and on request. Wall clock is the slowest block, about eight minutes, | |
| # instead of the sum of all of them. | |
| plan: | |
| name: List the README blocks | |
| if: github.event.schedule == '20 6 * * 1' || inputs.validate == true | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| blocks: ${{ steps.list.outputs.blocks }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: "20" | |
| - id: list | |
| env: | |
| ONLY: ${{ inputs.only }} | |
| run: | | |
| node - <<'JS' >> "$GITHUB_OUTPUT" | |
| import { readFileSync } from "node:fs"; | |
| // never execute anything inside the generated field table: its text comes from | |
| // the API, and a fence in a description must not become a command | |
| const readme = readFileSync("README.md", "utf8") | |
| .replace(/<!-- fields:start -->[\s\S]*?<!-- fields:end -->/g, ""); | |
| const SKIP = ["npm install", "npm i ", "export ", "npx -p @brightdata/cli bdata login", "bdata login"]; | |
| const blocks = []; | |
| const seen = new Map(); | |
| let heading = "README"; | |
| for (const m of readme.matchAll(/^#{2,3} ([^\n]+)$|```(javascript|bash)\n([\s\S]*?)```/gm)) { | |
| if (m[1]) { | |
| heading = m[1].trim(); // a block is named after its section, not its first line | |
| continue; | |
| } | |
| const lang = m[2]; | |
| let code = m[3]; | |
| let lines = code.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#")); | |
| if (lang === "bash") { | |
| lines = lines.filter((l) => !SKIP.some((s) => l.startsWith(s))); | |
| if (!lines.length) continue; | |
| // the CLI cannot open a browser here; the secret goes in by flag, and | |
| // only the shell ever sees its value | |
| code = lines | |
| .map((l) => (l.includes("bdata pipelines") ? `${l} -k "$BRIGHTDATA_API_TOKEN"` : l)) | |
| .join("\n"); | |
| } | |
| const n = (seen.get(heading) ?? 0) + 1; | |
| seen.set(heading, n); | |
| const title = n === 1 ? heading : `${heading} (${n})`; | |
| blocks.push({ lang, title: title.slice(0, 50), code }); | |
| } | |
| const wanted = new Set( | |
| (process.env.ONLY ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(Number), | |
| ); | |
| const out = blocks | |
| .map((b, i) => ({ n: i + 1, lang: b.lang, title: b.title })) | |
| .filter((b) => !wanted.size || wanted.has(b.n)); | |
| console.log("blocks=" + JSON.stringify(out)); | |
| JS | |
| block: | |
| name: "${{ matrix.n }}. ${{ matrix.title }}" | |
| needs: plan | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 # a block gives up at 8 minutes; past this it is stuck | |
| strategy: | |
| fail-fast: false | |
| # Three at a time, not all of them. On 2026-09-16 the API stalled in | |
| # waves: for stretches of an hour most jobs sat until the poll deadline | |
| # and returned no rows, then recovered and finished in 60 to 200 seconds. | |
| # Load did not explain it. Four concurrent jobs all passed during a good | |
| # stretch, and two jobs on their own both stalled during a bad one. The | |
| # cause is on the API side and is not established here. This cap does not | |
| # fix it. It limits how much of the matrix one bad wave can take down, | |
| # and leaves the per-block retry to land in a different wave. | |
| max-parallel: 3 | |
| matrix: | |
| include: ${{ fromJson(needs.plan.outputs.blocks) }} | |
| env: | |
| BRIGHTDATA_API_TOKEN: ${{ secrets.BRIGHTDATA_API_TOKEN }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: "20" | |
| - name: Install the package and, for CLI blocks, the Bright Data CLI | |
| run: | | |
| npm install | |
| # Two README blocks need this package installed, not just its deps. | |
| # The command block calls `linkedin-scraper` by name, and the import | |
| # block does `from "@brightdata/linkedin-scraper-node"`. npm install | |
| # alone gives neither, where pip install -e gave Python both. So pack | |
| # once and install it twice: globally for the command, and into | |
| # node_modules under its own name for the import. --no-save keeps the | |
| # self-reference out of package.json. | |
| tarball=$(npm pack --pack-destination "$RUNNER_TEMP" --silent) | |
| npm install -g "$RUNNER_TEMP/$tarball" | |
| npm install --no-save "$RUNNER_TEMP/$tarball" | |
| if [ "${{ matrix.lang }}" = "bash" ]; then npm i -g @brightdata/cli@0.3.4; fi | |
| - name: Run block ${{ matrix.n }} exactly as the README shows it | |
| env: | |
| N: ${{ matrix.n }} | |
| run: | | |
| set -o pipefail # without this, tee's exit code hides a failing block | |
| node - <<'JS' | tee -a "$GITHUB_STEP_SUMMARY" | |
| import { readFileSync } from "node:fs"; | |
| import { spawnSync } from "node:child_process"; | |
| import { setTimeout as sleep } from "node:timers/promises"; | |
| // never execute anything inside the generated field table: its text comes from | |
| // the API, and a fence in a description must not become a command | |
| const readme = readFileSync("README.md", "utf8") | |
| .replace(/<!-- fields:start -->[\s\S]*?<!-- fields:end -->/g, ""); | |
| const SKIP = ["npm install", "npm i ", "export ", "npx -p @brightdata/cli bdata login", "bdata login"]; | |
| const blocks = []; | |
| const seen = new Map(); | |
| let heading = "README"; | |
| for (const m of readme.matchAll(/^#{2,3} ([^\n]+)$|```(javascript|bash)\n([\s\S]*?)```/gm)) { | |
| if (m[1]) { | |
| heading = m[1].trim(); | |
| continue; | |
| } | |
| const lang = m[2]; | |
| let code = m[3]; | |
| let lines = code.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#")); | |
| if (lang === "bash") { | |
| lines = lines.filter((l) => !SKIP.some((s) => l.startsWith(s))); | |
| if (!lines.length) continue; | |
| code = lines | |
| .map((l) => (l.includes("bdata pipelines") ? `${l} -k "$BRIGHTDATA_API_TOKEN"` : l)) | |
| .join("\n"); | |
| } | |
| const n = (seen.get(heading) ?? 0) + 1; | |
| seen.set(heading, n); | |
| const title = n === 1 ? heading : `${heading} (${n})`; | |
| blocks.push({ lang, title: title.slice(0, 50), code }); | |
| } | |
| const b = blocks[Number(process.env.N) - 1]; | |
| const secret = process.env.BRIGHTDATA_API_TOKEN ?? ""; | |
| let out = ""; | |
| let err = ""; | |
| let ok = false; | |
| let secs = 0; | |
| let status = null; | |
| for (const attempt of [1, 2]) { | |
| const started = Date.now(); | |
| // a README javascript block is ESM: node reads it from stdin under --input-type | |
| const run = | |
| b.lang === "bash" | |
| ? spawnSync("bash", ["-e", "-c", b.code], { encoding: "utf8", timeout: 480_000 }) | |
| : spawnSync(process.execPath, ["--input-type=module"], { | |
| input: b.code, | |
| encoding: "utf8", | |
| timeout: 480_000, | |
| }); | |
| secs = Math.round((Date.now() - started) / 1000); | |
| out = (run.stdout ?? "").trim(); | |
| err = (run.stderr ?? "").trim(); | |
| if (run.error?.code === "ETIMEDOUT" || run.signal) { | |
| err = err || "block timeout: no result within 8 minutes"; | |
| } | |
| if (secret) { // summaries are not secret-masked the way logs are | |
| out = out.replaceAll(secret, "***"); | |
| err = err.replaceAll(secret, "***"); | |
| } | |
| status = run.status; | |
| ok = status === 0 && Boolean(b.lang === "javascript" ? out : out || err); | |
| if (ok || ((out || err) && !`${out}${err}`.toLowerCase().includes("timeout"))) break; | |
| if (attempt === 1) await sleep(30_000); // the API timed out or said nothing; one retry | |
| } | |
| // A block that exits 0 and prints nothing is the confusing case: the | |
| // snippet ran, the API answered, and the loop had no rows to print. | |
| // Saying "no output" alone sends the next reader looking for a crash | |
| // that never happened, so name the shape of the failure instead. | |
| const quiet = status === 0 && !out && !err; | |
| const shown = ok | |
| ? (out.split("\n")[0] ?? "") | |
| : quiet | |
| ? "exited 0 and printed nothing: the call returned no rows" | |
| : (err.split("\n").at(-1) || out.split("\n").at(-1) || `no output, exit ${status}`); | |
| console.log(`\n\`${b.title}\`: ${ok ? "ok" : "FAILED"} in ${secs}s\n\n ${shown.slice(0, 120)}`); | |
| process.exit(ok ? 0 : 1); | |
| JS | |
| badge: | |
| name: Put today's date on the README's "last verified" badge | |
| needs: [scrape, plan, block] | |
| # scheduled and manual runs only, and only in the main repository: a push is | |
| # never followed by a bot commit, and a copy never diverges from the original. | |
| # | |
| # `inputs.only` empty as well: a run of one block that passes is not the | |
| # README verified, and a green badge has to mean the whole of it. A | |
| # scheduled run has no inputs at all, where `inputs.only` is null and this | |
| # comparison is still true, so the daily and Monday runs still set it. | |
| if: always() && !cancelled() && github.event_name != 'push' && inputs.only == '' && github.repository == 'brightdata/linkedin-scraper-node' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| # The tip of main, not this run's commit. A re-run checks out the | |
| # commit the run first started on, but an earlier attempt of the same | |
| # run may already have pushed a badge edit to this very line. Editing | |
| # from that stale base gives git two versions of one line, and the | |
| # rebase below stops on a conflict. From the tip, the line is replaced, | |
| # never merged. This broke on 2026-09-18 when a failed run was re-run. | |
| ref: main | |
| - name: Rewrite one badge line, commit only if it changed | |
| env: | |
| SCRAPE: ${{ needs.scrape.result }} | |
| BLOCKS: ${{ needs.block.result }} # skipped on the daily run, and that is fine | |
| run: | | |
| if [ "$SCRAPE" = "success" ] && { [ "$BLOCKS" = "success" ] || [ "$BLOCKS" = "skipped" ]; }; then | |
| label="last verified"; color="brightgreen" | |
| else | |
| label="last check failed"; color="red" | |
| fi | |
| today=$(date -u +'%-d %b %Y') | |
| line="[](https://github.com/brightdata/linkedin-scraper-node/actions/workflows/live.yml) <!-- verified: rewritten by the daily run -->" | |
| LINE="$line" node - <<'JS' | |
| import { readFileSync, writeFileSync } from "node:fs"; | |
| const path = "README.md"; | |
| const text = readFileSync(path, "utf8"); | |
| const pattern = /^\[!\[last [^\]]*\]\(https:\/\/img\.shields\.io\/badge\/[^)]*\)\]\([^)]*\) <!-- verified:[^\n]*$/m; | |
| if (!pattern.test(text)) { | |
| console.error("the badge line is missing from README.md"); | |
| process.exit(1); | |
| } | |
| writeFileSync(path, text.replace(pattern, () => process.env.LINE), "utf8"); | |
| JS | |
| if git diff --quiet; then echo "badge already shows $today"; exit 0; fi | |
| git config user.name "anil-bd" | |
| git config user.email "210667061+anil-bd@users.noreply.github.com" | |
| # $label, not a fixed "Last verified": the log should not claim a | |
| # check passed on a day the badge went red. | |
| git commit -qam "${label^} $today (automated daily check)" | |
| git pull -q --rebase origin main | |
| git push -q origin main | |
| echo "badge now shows: $label, $today" |