Ventilator is a small, reusable GitHub Actions heartbeat for low-traffic APIs and websites. It can make a bounded HTTPS request or load a JavaScript-rendered page and wait for expected visible text—without placing credentials in the workflow or URL.
It works with services that expose an HTTP health or read endpoint, including Supabase REST APIs, and with public web pages whose content must be checked in a real browser. Ventilator is a heartbeat, not a full uptime monitor: it fails the workflow when the endpoint or expected page content is unavailable, while GitHub handles run history and notifications.
Create .github/workflows/keep-alive.yml in the repository that will own the schedule:
name: Keep services active
on:
schedule:
- cron: "17 */5 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
heartbeat:
uses: codefromrvk/ventilator/.github/workflows/heartbeat.yml@v1
with:
endpoint_name: Example API
url: https://api.example.com/healthThe example runs at minute 17 of every fifth UTC hour. workflow_dispatch also adds a Run workflow button for manual checks.
Use website_url instead of url when the content is added by JavaScript. Ventilator loads the page in Chrome and waits up to 30 seconds for the text to appear in the page's rendered visible text. Matching is literal apart from capitalization:
jobs:
website:
uses: codefromrvk/ventilator/.github/workflows/heartbeat.yml@v1
with:
endpoint_name: World Cup schedule
website_url: https://worldcup2023.varshithkumar.com/
keyword_string_to_wait: "Sun, Nov 19, 2 PM GMT+5:30"
timezone_id: Asia/KolkataChoose one mode per job: either url for an API request, or both website_url and keyword_string_to_wait for a browser check.
Warning
Use a private repository for unattended cron schedules. GitHub supports
scheduled workflows in public repositories, but automatically disables them
after 60 days without repository activity. See GitHub's official
schedule event documentation.
Using a private caller repository also keeps endpoint names and operational configuration out of the public repository.
uses: codefromrvk/ventilator/.github/workflows/heartbeat.yml@v1| Name | Required | Default | Description |
|---|---|---|---|
endpoint_name |
Yes | — | Friendly check name shown in logs. Do not include secrets. |
url |
API mode | Empty | HTTPS API endpoint to request. Credentials must not be included in the URL. Cannot be combined with website_url. |
website_url |
Website mode | Empty | Public HTTPS page on port 443 to load in Chrome. It must remain on the configured origin, be paired with keyword_string_to_wait, and cannot be combined with url. |
keyword_string_to_wait |
Website mode | Empty | Text of up to 500 characters to find in the rendered visible body text. Matching ignores capitalization. |
timezone_id |
No | UTC |
Website mode IANA timezone, such as Asia/Kolkata. Use this when page text depends on the visitor's timezone. |
method |
No | GET |
API mode request method: GET or HEAD. |
expected_status |
No | 2xx |
API mode expectation: 2xx or a specific non-redirect HTTP status such as 204. Exact 3xx values are not supported. |
| Name | Required | Description |
|---|---|---|
request_headers_json |
No | API mode only. JSON object of up to 20 HTTP header names and string values. Names may contain letters, numbers, and hyphens. Values are masked before the request runs. |
Store the complete JSON object as one GitHub Actions repository secret. Never commit it to the caller workflow.
No secret mapping is needed for a public health endpoint:
jobs:
heartbeat:
uses: codefromrvk/ventilator/.github/workflows/heartbeat.yml@v1
with:
endpoint_name: Public health check
url: https://api.example.com/health
method: HEAD
expected_status: "204"Create a repository secret named SERVICE_HEADERS_JSON:
{"Authorization":"Bearer replace-with-token"}Pass it to Ventilator:
jobs:
heartbeat:
uses: codefromrvk/ventilator/.github/workflows/heartbeat.yml@v1
with:
endpoint_name: Protected API
url: https://api.example.com/health
secrets:
request_headers_json: ${{ secrets.SERVICE_HEADERS_JSON }}Create SERVICE_HEADERS_JSON with the header required by your provider:
{"x-api-key":"replace-with-api-key"}Use the same secrets.request_headers_json mapping shown above.
Choose a cheap, read-only REST query, such as selecting one public row. Keep the query bounded with limit=1:
jobs:
supabase:
uses: codefromrvk/ventilator/.github/workflows/heartbeat.yml@v1
with:
endpoint_name: Supabase database
url: https://YOUR_PROJECT_REF.supabase.co/rest/v1/YOUR_PUBLIC_TABLE?select=id&limit=1
secrets:
request_headers_json: ${{ secrets.SUPABASE_HEADERS_JSON }}For a current sb_publishable_... key, set SUPABASE_HEADERS_JSON with the apikey header:
{
"apikey": "YOUR_SUPABASE_PUBLISHABLE_KEY"
}If the project still uses a legacy JWT-based anon key, Supabase's traditional REST pattern sends that key in both headers:
{
"apikey": "YOUR_LEGACY_ANON_KEY",
"Authorization": "Bearer YOUR_LEGACY_ANON_KEY"
}Do not put a current publishable key in Authorization; it is not a JWT. Never use a Supabase service_role or secret key for a heartbeat. Those keys bypass Row Level Security and are unnecessary for a minimal public read. Review Supabase API key guidance and ensure the chosen query is permitted by your Row Level Security policies.
Add one job per check. Jobs run independently, so one failure does not prevent another check from running:
jobs:
api:
uses: codefromrvk/ventilator/.github/workflows/heartbeat.yml@v1
with:
endpoint_name: API
url: https://api.example.com/health
database:
uses: codefromrvk/ventilator/.github/workflows/heartbeat.yml@v1
with:
endpoint_name: Database API
url: https://database.example.com/rest/v1/health?select=id&limit=1
secrets:
request_headers_json: ${{ secrets.DATABASE_HEADERS_JSON }}
website:
uses: codefromrvk/ventilator/.github/workflows/heartbeat.yml@v1
with:
endpoint_name: Public website
website_url: https://example.com/
keyword_string_to_wait: Example Domain- Accepts exactly one mode per job: an API request or a rendered website check.
- In API mode, validates the method, expected status, and optional header JSON before sending a bounded
curlrequest. - In website mode, runs a checksum-pinned browser client against the runner's sandboxed Chrome and waits for a substring in rendered
innerText, ignoring capitalization. - Uses
UTCfor website rendering unlesstimezone_idspecifies another IANA timezone. - Masks API header values and does not print response bodies or expected website text.
- Gives the browser job no GitHub token permissions and never passes API header secrets to it.
- Fails the job when the API status is unexpected or the website text does not appear within 30 seconds.
- GitHub scheduled workflows are best-effort and can be delayed during periods of high load. Schedule critical work on infrastructure with delivery guarantees instead. See GitHub's
scheduleevent documentation. - GitHub automatically disables scheduled workflows in public repositories after 60 days without repository activity. A private caller repository is recommended for unattended heartbeats.
- Website mode checks the initial page load and rendered body text only. It does not click, sign in, or validate a complete user journey.
- Website mode does not support authentication, non-standard HTTPS ports, WebSockets, or pages that require
POSTrequests to render the expected text. - Ventilator does not provide latency history, geographic probes, escalation policies, or service-level monitoring.
- A heartbeat may not satisfy every provider's activity policy. Check the provider's current terms and pausing rules.
- GitHub Actions usage and retention are subject to the caller repository's plan and settings.
Confirm the secret is a valid JSON object of no more than 20 headers whose keys and values are strings. Header names may contain letters, numbers, and hyphens. JSON requires double quotes. Do not paste shell flags or a YAML map into the secret.
Check the credential type, required header names, and the endpoint's authorization policy. For Supabase, verify Row Level Security allows the intended read for the publishable or anon role.
Ventilator intentionally does not follow redirects, and expected_status rejects exact 3xx values. Change url to the final HTTPS endpoint so authorization headers never cross hosts.
Confirm the text is visible in the rendered page and appears within 30 seconds. keyword_string_to_wait is a substring match that ignores capitalization; hidden DOM text does not count. If the page formats dates or times for the visitor, set timezone_id to the timezone represented by the expected text. Browser mode does not accept authentication headers or perform clicks.
GitHub schedules can be delayed. Use Actions → Keep services active → Run workflow to verify the configuration manually.
Check whether GitHub disabled the workflow after repository inactivity, then re-enable it from the Actions tab. Keeping the caller private avoids the public-repository inactivity rule.
Use @v1 for compatible updates within major version 1. Pin to @v1.1.1 when you need the immutable release with website checks and configurable browser timezones. Breaking interface or behavior changes are released under a new major tag.
Maintainers can follow the manual release checklist in RELEASE.md.
Delete or disable the caller workflow to stop scheduled requests, then remove its header secrets from Repository settings → Secrets and variables → Actions. If you created a credential only for Ventilator, revoke it with the service provider as well.
Contributions are welcome. Read CONTRIBUTING.md before opening a pull request.
Do not include credentials, private endpoints, or response data in an issue. Report security concerns privately using the instructions in SECURITY.md.
Ventilator is available under the MIT License.