Publishes approved posts at the time each file names.
An Azure Function, named after the messenger who announces at a fixed hour.
v0.2 reads the content repository and reports what a run would do: it selects the post files from the git tree, parses their front matter, and says per post whether a target is due, still scheduled, overdue or already served. Nothing is published and nothing is written back.
What follows is the write-back of the results, the rendering per target, and the target clients themselves.
Most of what follows is what herald is built to do. The status above says what already runs.
The repository is the only state
Status, post URN and timestamp live in the post's own front matter.
There is no database, no table, no second store to reconcile. Whether a post went out is answered by the file that contains it.
A post can never go out twice
The file is committed as published before the API call.
A crash between the two leaves a post that is live without its URN recorded, which is recoverable by hand and described in the runbook.
It never leaves a post that gets published again on the next run.
Own comments live in the post file, with their own schedule
Article link, series comment, a follow-up three days later: each one sits in the front matter with a relative time or an absolute one, and is published and recorded like the post itself.
A series comment comes from a template that references other posts by slug.
Each reference renders to that post's URL once that post is published and disappears while it is not, so the list is never wrong and never needs editing.
A post names its targets
Parser, scheduler and escaping deal with posts, comments and times. No network appears in them.
The front matter names the targets a post goes to and carries one result block per target, because two targets return two post ids and two timestamps.
What is due is then decided per target: approved, time reached, no result recorded yet.
Dry-run is what an unconfigured app does
Without Herald__Mode=Live nothing is sent and nothing is written.
The dry run logs what it would have done, including the escaped text.
The official API, and nothing else
No scraping, no unofficial endpoints, no browser automation - the three things that get accounts suspended.
No secret is ever in the repository or in the pipeline
Tokens live in Key Vault and reach the app as key vault references.
The app talks to its storage with a managed identity and shared keys are disabled, so no key reaches the Terraform state either.
The pipeline signs in to Azure with OIDC and holds no credential at all.
A scheduled GitHub Actions workflow would be the obvious host: no Terraform, no Key Vault, no identity, no cost.
Three documented properties of the schedule event argue against it for publishing at a named time:
- "The
scheduleevent can be delayed during periods of high loads of GitHub Actions workflow runs. High load times include the start of every hour." - "In a public repository, scheduled workflows are automatically disabled when no repository activity has occurred in 60 days."
- "The shortest interval you can run scheduled workflows is once every 5 minutes."
The second one is the reason. A publisher is finished software: once it works it is not touched for months. A schedule that switches itself off after 60 quiet days fails silently, and a quiet repository looks like a stable one.
The delay is the weaker argument: the timer polls every ten minutes anyway, so a post goes out somewhere inside a ten-minute window either way.
A ten-minute cron would however fire at :00, which is the load peak the documentation names.
The access token never leaves Azure. As a GitHub secret it would be readable by every workflow in the repository and injected into a runner on every run.
The domain logic is testable, because parser, scheduler and escaping are pure functions in Herald.Core with no Azure and no HTTP dependency.
Application Insights makes runs queryable across days, which the per-function scaling observation needs and per-run workflow logs cannot give.
The cost of that: more moving parts, Terraform, two tokens to rotate instead of one, and a 30-second app-initialization timeout on Flex Consumption. Money is not part of that cost: Actions minutes are free for public repositories, and this workload sits inside the Flex Consumption free grant.
A post is published exactly when its Markdown file says status: approved, its publish_at has passed and it carries no urn yet.
After publishing, herald writes status, urn and published_at back to the file as a commit.
Reads and writes go through the GitHub REST API with a fine-grained token scoped to that one repository.
The three functions use three different triggers, which is what makes per-function scaling on Flex Consumption visible:
| Function | Trigger | Purpose |
|---|---|---|
PublishScheduledPosts |
timer, 0 */10 * * * * |
publish due posts and their comments |
CheckTokenExpiry |
timer, 0 0 6 * * * |
open an issue before an access token expires |
RunNow |
HTTP, authorization level Function |
carry out a run on demand, return its report |
ReportVersion |
HTTP, authorization level Function |
report which code is deployed, for the smoke test |
All schedules are UTC: Flex Consumption does not support WEBSITE_TIME_ZONE or TZ.
src/Herald.Functions function app: the three triggers, host wiring
src/Herald.Core domain logic and configuration, no Azure dependencies
src/Herald.Core.Test.Unit unit tests for Herald.Core, xUnit v3 on Microsoft.Testing.Platform
build/ TargetFramework and central package versions
deploy/ Terraform
docs/ target POST API facts, content format, runbook
scripts/ bootstrap, token exchange
.github/workflows/ ci, infra, deploy
Directory.Build.props and Directory.Packages.props at the root are two-line shims.
The real content is in build/, where MSBuild would not find it on its own.
cp local.settings.template.json src/Herald.Functions/local.settings.json
cd src/Herald.Functions
dotnet rundotnet run is the entry point with Azure.Functions.Sdk. It starts the Functions host when Core Tools is installed.
The template sets Herald__Mode to Dry and fills in the content settings with example values. Replace Herald__Content__Repository, Herald__Content__PostPattern and Herald__Content__TemplateFolder with the values of the repository variables CONTENT_REPOSITORY, CONTENT_POST_PATTERN and CONTENT_TEMPLATE_FOLDER, and Herald__Content__Token with a fine-grained token for that repository.
Removing a required setting, or giving it an invalid value, makes the app fail at start-up with a message naming the setting.
That is deliberate, because a failed start on Flex Consumption offers no other diagnosis.
local.settings.json is in .gitignore and never holds a real token in a committed file.
Carry out a run against the content repository and read what it found:
curl "http://localhost:7071/api/RunNow"The timer carries out the same run every ten minutes, so what you see here is what a scheduled run does. ReportVersion is the other HTTP trigger and answers which code is deployed; it reads no content repository, which is why the deployment smoke test calls that one and not this.
dotnet restore src/Herald.Functions/Herald.Functions.csproj
dotnet build Herald.slnx --no-restore -warnaserror
dotnet test --solution Herald.slnx --no-buildglobal.json selects Microsoft.Testing.Platform as the test runner, which xUnit v3 requires on the .NET 10 SDK.
That is why dotnet test takes the solution through --solution.
Restore the function project itself, not only the solution:
Only a direct project restore runs the post-restore hook that generates the extension project obj/azure_functions/azure_functions.g.csproj (AZFW0108).
| Setting | Meaning |
|---|---|
Herald__Mode |
Dry (default in the template) or Live, matched case-insensitively. (Required) |
Herald__OverdueLimit |
How long a post stays due after its time has passed, as a TimeSpan such as 24:00:00. Defaults to 24 hours. |
Herald__TokenExpiryWarning |
How long before a token expires herald opens an issue about it, as a TimeSpan such as 30.00:00:00. Defaults to 30 days. |
Herald__Content__Repository |
The content repository as owner/name. Reads and writes go through the GitHub REST API against this one repository. (Required) |
Herald__Content__Token |
Access token of the content repository, a fine-grained token scoped to that one repository. A key vault reference in Azure. (Required) |
Herald__Content__Branch |
Branch herald reads the posts from and writes its results back to. Defaults to main. |
Herald__Content__PostPattern |
Glob that selects the post files, relative to the repository root, ending in .md. A single * does not cross a /. (Required) |
Herald__Content__TemplateFolder |
Folder that holds the comment templates, relative to the repository root. Files in it are never read as posts. (Required) |
Herald__Content__CommitMessageSuffix |
Appended to every commit message herald writes into the content repository. Defaults to [skip ci], and an empty value turns it off. |
There are two stages, dev and prd: the same Terraform configuration with a different value for the required variable environment, which also selects the state file.
Only the infra workflow applies it. Locally there is terraform plan and nothing else:
terraform -chdir=deploy init -backend-config="key=herald-dev.tfstate"
terraform -chdir=deploy plan -var="environment=dev" -var="app_version=0.2.0"- Sign in with
az login, and for a private repository also withgh auth login. - Run
scripts/bootstrap.ps1 <owner>/<repository> dev, then the same withprd. It creates the state store, one pipeline identity per stage and a plan identity, each with its federated credential, in a resource group Terraform does not manage. It is idempotent and prints every value the next steps need. - Create the GitHub environments
devandprd, and giveprda required reviewer. - Add
AZURE_CLIENT_IDas an environment secret on each stage, andAZURE_TENANT_ID,AZURE_SUBSCRIPTION_IDandAZURE_PLAN_CLIENT_IDas repository secrets. None of them is confidential, but secrets are redacted in workflow logs. - Set the repository variables
CONTENT_REPOSITORY,CONTENT_POST_PATTERNandCONTENT_TEMPLATE_FOLDERto the values of the three required content settings, andDEPLOY_ENABLEDtotrue. WithoutDEPLOY_ENABLEDinfraanddeployskip every job. - Merge to
main.infraappliesdev, thenprd. - Write the access token of the content repository into the vault of each stage. Terraform creates the vault and the function app's access to it and never the value, so this is the one step that handles the token:
az keyvault secret set --vault-name <key_vault_name from the terraform output> --name github-content-token --value <token>. The command lands in the shell history, so use--fileinstead where that matters. The app reads the secret through a key vault reference and resolves it again after a rotation, as long as the secret keeps its name. - Deploy the code once by hand, because
deploystarts on its own only whensrc/orbuild/changes:gh workflow run deploy.yml -f sha=<sha of main>.
Role assignments take a few minutes to become effective. If the first run fails in terraform init with an authorization error, run it again.
| Identity | Role | Scope | Why |
|---|---|---|---|
| Pipeline, one per stage | Contributor |
subscription | create the app resource group and everything in it |
Role Based Access Control Administrator |
subscription | give the function app identity its storage roles | |
Storage Blob Data Contributor |
state storage account | write the state | |
| Plan, shared | Reader |
subscription | sign in and read the subscription |
Storage Blob Data Reader |
state storage account | read the state | |
| Function app | Storage Blob Data Owner |
app storage account | host storage and deployment package |
Storage Table Data Contributor |
app storage account | host diagnostic events |
An ABAC condition limits the right to assign roles: the pipeline identity may assign and remove exactly those two storage roles, and only to service principals.
Contributor sits on the subscription because the app resource group does not exist before the first apply, which assumes one subscription per workload.
A pull request plan runs without a lock and without a refresh, so it shows no drift. The apply on main refreshes first.
| Workflow | Trigger | What it does |
|---|---|---|
ci |
pull request, push to main |
build, test, check Terraform, publish the artifact on main |
infra |
deploy/, the infra workflows, build/Directory.Build.props |
plan both stages on a pull request, apply dev then prd on main |
deploy |
green ci on main with changes in src/ or build/, manual run |
deploy to dev, smoke-test, then the same for prd |
The smoke test checks that ReportVersion reports the commit it just deployed. A rollback is a manual deploy run with the SHA of the last green commit.
Every resource carries a version tag with the release version from build/Directory.Build.props.
Both stages run with Herald__Mode set to Dry. To publish from prd, set the repository variable HERALD_MODE_PRD to Live and run infra.
herald appends a marker to every commit message it writes back, so that a status change in front matter does not set off a build in the content repository.
The marker is configurable. [skip ci] is the default because GitHub Actions honours it. Other systems expect their own token, and an empty value turns the marker off for a repository that does want the build.
Whether a push to a post file should trigger a build at all is a question for the content repository and not for herald.
The usual answer there is a path filter on the push workflow - paths-ignore under GitHub Actions, the equivalent elsewhere.
That change lives in the content repository and is not made from here.
MIT, see LICENSE. Use it, fork it, take pieces out of it. The only obligation is the one MIT states: keep the copyright notice in copies or substantial portions.
The name herald and the logo files under docs/images/ are reserved.
MIT conveys no trademark rights, and the logo is how the project is recognised, so a fork should carry its own name and its own artwork.
If something here saved you an afternoon, a star is welcome.