Skip to content
fzanklPublic

About

Publishes approved posts from Markdown files at the time each file names. Azure Functions on Flex Consumption, deployed with Terraform and GitHub Actions over OIDC, without a stored secret.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

herald

Publishes approved posts at the time each file names.


An Azure Function, named after the messenger who announces at a fixed hour.

Status

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.

Why this exists

Most of what follows is what herald is built to do. The status above says what already runs.

Everything lives in the post file

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.

What it refuses to do

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.

Timer trigger against scheduled workflow

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 schedule event 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.

Architecture

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.

Repository layout

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.

Running locally

cp local.settings.template.json src/Herald.Functions/local.settings.json
cd src/Herald.Functions
dotnet run

dotnet 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.

Building

dotnet restore src/Herald.Functions/Herald.Functions.csproj
dotnet build Herald.slnx --no-restore -warnaserror
dotnet test --solution Herald.slnx --no-build

global.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).

Configuration

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.

Deployment

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"

Setting up

  1. Sign in with az login, and for a private repository also with gh auth login.
  2. Run scripts/bootstrap.ps1 <owner>/<repository> dev, then the same with prd. 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.
  3. Create the GitHub environments dev and prd, and give prd a required reviewer.
  4. Add AZURE_CLIENT_ID as an environment secret on each stage, and AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID and AZURE_PLAN_CLIENT_ID as repository secrets. None of them is confidential, but secrets are redacted in workflow logs.
  5. Set the repository variables CONTENT_REPOSITORY, CONTENT_POST_PATTERN and CONTENT_TEMPLATE_FOLDER to the values of the three required content settings, and DEPLOY_ENABLED to true. Without DEPLOY_ENABLED infra and deploy skip every job.
  6. Merge to main. infra applies dev, then prd.
  7. 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 --file instead 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.
  8. Deploy the code once by hand, because deploy starts on its own only when src/ or build/ 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.

Who may do what

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.

The workflows

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.

A note for the content repository

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.

Licence

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.

About

Publishes approved posts from Markdown files at the time each file names. Azure Functions on Flex Consumption, deployed with Terraform and GitHub Actions over OIDC, without a stored secret.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages