A fast build is not an on-time publication: recovering daily GitHub Actions

Use Azure Automation to trigger GitHub-hosted publishing workflows, correlate exact runs and verify real Sydney-date publication without adding a runner VM.

Status as of November 8, 2026. Azure Automation now dispatches both of our sites to GitHub-hosted runners, and the runner VM that preceded this design has been retired. The first unattended due-day cycles, on November 10 and 11, have not happened yet, so this post makes no unattended-reliability claim. All new deployable files are included below, with placeholders for your own organization, domains and Azure resources.

The build was fast. The post was late.

Our publication deadline was 7 AM in Sydney. In the November 5 incident review, GitHub created that day’s scheduled run 6 hours and 31 minutes after its cron time, while deployment took less than three minutes. Reported completion times for November 3, 4 and 5 were 9:48 AM, 7:34 AM and 7:50 AM. Moving the cron earlier had not solved the delay.

The evidence pointed to a trigger problem, not a build-performance problem. We needed a publishing cycle that could start independently of GitHub’s cron scheduler and verify that the correct content actually reached readers.

Our first iteration added a shared runner VM. Then we challenged that choice. The conclusion was direct: GitHub’s best-effort cron trigger was the problem, and Azure Automation could dispatch workflows to GitHub-hosted runners without a VM.

That is the lesson behind this implementation: replace the unreliable component, not every component beside it.

The Mental Model

Separate four events: the trigger becomes due, GitHub creates a run, a runner executes it, and the expected content becomes publicly available. A green build does not establish when the scheduled event was admitted. An HTTP 200 does not establish that the response contains the intended page.

The incident established delay before run creation. It did not establish a six-hour runner queue or a six-hour Azure deployment. GitHub documents that scheduled events can be delayed under load, especially at the start of an hour, and that sufficiently high load can drop queued scheduled jobs.

The live design retains GitHub Actions and its hosted Ubuntu runners for execution. Azure Automation owns timed dispatch through a GitHub App. The controller follows the exact dispatched runs, and the site workflows retain their publication checks. The requirement is publication between 00:30 and 07:00 in Australia/Sydney, on each site’s selected weekdays.

Options and the reason for simplifying

OptionEvidence or tradeoffDecision
Move native cron off the hour or earlierTried; late creation persistedKeep as a fallback, not the primary deadline trigger
Add more native cronsStill uses the same best-effort scheduling platformDoes not resolve the central dependency
Advance Hugo’s clock or enable future buildsMakes content appear before its intended dateRejected
External scheduling plus a self-hosted VMAdds runner administration and compute lifecycleOver-engineered first iteration for this workload
External scheduling plus hosted runnersChanges trigger ownership while retaining working executionSelected and live

The organization is on the GitHub Free plan. Extrapolating the two supervised hosted runs to two publications per site per week gives approximately 61 rounded Linux minutes per month. That excludes pushes, pull-request previews, retries and other repositories’ workflows, which draw on the same included allowance. Compare the total with the plan’s current included minutes in GitHub’s billing documentation.

The initial VM design carried an estimated A$9-10 per month for VM compute, disk and public IP within an approximately A$25/month overall allowance. Those were planning estimates, not actual bills. It also added runner registration, a restricted runner group, extra App permissions, bootstrap and toolchain maintenance, shutdown safeguards and a continuously scheduled watchdog. The missing Go toolchain on the fresh VM was another integration issue. No VM-specific requirement was documented. Those additions did not follow from the evidence that the cron trigger was late.

The VM is therefore historical context, not a deployment recommendation or part of the implementation guide. It has been retired.

How It Really Works

This section describes the live architecture established by the cutover evidence. Code-level behavior, including retry, idempotency and state handling, follows the separately confirmed final infrastructure source.

flowchart TD S1[00:30 Sydney dispatch] --> C[Weekday-aware controller] S2[01:30 Sydney recovery] --> C S3[06:30 Sydney verification through 07:00] --> C C --> K[GitHub App authentication via managed identity and Key Vault] K --> G[Dispatch workflow with cycle identity and real Sydney date] G --> R[GitHub-hosted ubuntu-latest] R --> B[Normal static-site build] B --> P[Verify due content and publication exclusions] P --> U[Upload prebuilt output to Static Web Apps] U --> V[Verify expected public content] V --> T[Exact correlated run result] T --> C C --> A[Failure and due-day absence alerts]

Three daily Azure Automation schedules run in Australia/Sydney time: 00:30 dispatches due sites, 01:30 runs recovery, and 06:30 verifies existing runs through the 07:00 deadline. They replaced the VM design’s four continuously staggered watchdog schedules.

The controller selects each site’s weekdays from the real Sydney date. The blog publishes on Tuesday and Thursday; AI Strategy publishes on Wednesday and Friday. An unscheduled day is not a missed publication: on Sunday, November 8, both controller entry points completed as no-ops without dispatching anything.

The code validates canonical, nonempty, unique weekday arrays and exactly the two repositories. Config accepts an object or up to two JSON-string layers; invalid shapes fail. The off-day path returns before identity/network access, lease acquisition or writes.

On due days, the 60-second lease serializes creation/resumption of schema-3 execution: github-hosted state. The initial job polls for up to 15 minutes; recovery at 01:30 resumes existing state or creates a missing cycle while admission is open. At 06:30 it can observe existing runs until 07:00 but cannot create or dispatch missing work. Requests and 20-second polling sleeps renew the lease. A lease failure halts side effects.

The code persists ambiguous before POST. A timeout/failed POST is never automatically repeated: recovery searches up to 250 workflow_dispatch/main runs using the exact display title, rejects duplicate/truncated results and records the discovered ID. A known run is checked for title, branch and event before status/conclusion updates. Same-day failed terminal runs are not redispatched as a new cycle. A running previous-day cycle cannot be overwritten; finished historical state does not count as today’s verified hosted success.

Timely success requires the exact nonempty due-target set, all recorded run IDs completed with success, no error and verification before 07:00. The controller trusts terminal workflow success, whose jobs include public-content checks; it does not independently fetch the site. Late completion is recorded as failure. Hosted runs are not cancelled at the old VM deadline. A job failing admission at 06:30 is not permission to bypass the window.

Each dispatch must be tied to a cycle identity and the real local publication date. Run creation and terminal success are separate observations. Correlation must not select the latest unrelated manual run. Cancellation, failure, duplicate matches and ambiguous dispatch responses must remain explicit rather than becoming a success-shaped fallback.

Both supervised runs used the exact run name Azure daily publish / <cycle_id>, with one shared cycle UUID. With overlapping configured days, the controller persists the first run ID before dispatching the next target; it does not wait for first-run completion. Current disjoint weekdays normally select one site.

Implementation Guide

Prerequisites

The guide adds scheduling to two existing private static-site repositories already deployed to Azure Static Web Apps: one Hugo and one Eleventy site. Existing content, templates, dependency lockfiles, production domains and deployment-token secrets remain prerequisites. This is not a greenfield website generator.

Reader tooling includes authenticated Azure CLI with its Bicep compiler, PowerShell 7.4+ for operator scripts, GitHub access and each site’s existing build tools. The Hugo workflow uses Hugo 0.152.2 extended, Go 1.24.x for Hugo modules and Node.js 22. Eleventy uses Node.js 20 and the existing lockfile. Azure uses the PublishingPowerShell74 runtime. None of the old runner VM’s tooling assumptions remain deployment requirements.

Automation foundation

Save these controller files under one local directory. The historical filename does not provision a runner: the foundation contains only Automation/runtime/config, storage/container, Key Vault and scoped state/key RBAC. App and installation IDs are deployment parameters, never private keys.

Automations\infra\runner.bicep

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
targetScope = 'resourceGroup'

param location string = 'australiaeast'
param automationName string = 'aa-gh-publishing'
param storageName string = 'stghpub${uniqueString(resourceGroup().id)}'
param vaultName string = 'kv-ghpub-${uniqueString(resourceGroup().id)}'
param githubAppId string = ''
param githubInstallationId string = ''
param githubKeySecretName string = 'github-app-private-key'
param alertActionGroupId string = ''

var runnerConfig = {
  subscriptionId: subscription().subscriptionId
  resourceGroup: resourceGroup().name
  storageAccount: storage.name
  stateContainer: 'cycles'
  vaultName: vault.name
  githubAppId: githubAppId
  githubInstallationId: githubInstallationId
  githubKeySecretName: githubKeySecretName
  organization: 'ExamplePublishing'
  timeZone: 'Australia/Sydney'
  alertReceiver: alertActionGroupId
  workflows: [
    {
      repository: 'blog'
      workflow: 'azure-static-web-apps-red-flower-083307e00.yml'
      ref: 'main'
      publishDays: ['Tuesday', 'Thursday']
    }
    {
      repository: 'ai-strategy-blog'
      workflow: 'azure-static-web-apps.yml'
      ref: 'main'
      publishDays: ['Wednesday', 'Friday']
    }
  ]
}

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageName
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  properties: {
    allowBlobPublicAccess: false
    allowSharedKeyAccess: false
    minimumTlsVersion: 'TLS1_2'
    supportsHttpsTrafficOnly: true
    publicNetworkAccess: 'Enabled'
  }
}
resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
  parent: storage
  name: 'default'
  properties: {}
}
resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = {
  parent: blobs
  name: 'cycles'
  properties: { publicAccess: 'None' }
}
resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = {
  name: vaultName
  location: location
  properties: {
    tenantId: subscription().tenantId
    sku: { family: 'A', name: 'standard' }
    enableRbacAuthorization: true
    enableSoftDelete: true
    enablePurgeProtection: true
    softDeleteRetentionInDays: 7
    publicNetworkAccess: 'Enabled'
    accessPolicies: []
  }
}
resource automation 'Microsoft.Automation/automationAccounts@2024-10-23' = {
  name: automationName
  location: location
  identity: { type: 'SystemAssigned' }
  properties: {
    sku: { name: 'Basic' }
    publicNetworkAccess: true
    disableLocalAuth: true
  }
}
resource runtime 'Microsoft.Automation/automationAccounts/runtimeEnvironments@2024-10-23' = {
  parent: automation
  name: 'PublishingPowerShell74'
  location: location
  properties: {
    runtime: { language: 'PowerShell', version: '7.4' }
    defaultPackages: {}
  }
}
resource config 'Microsoft.Automation/automationAccounts/variables@2024-10-23' = {
  parent: automation
  name: 'RunnerConfig'
  properties: { isEncrypted: false, value: string(runnerConfig) }
}
resource stateControl 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(container.id, automation.id, 'state')
  scope: container
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')
    principalId: automation.identity.principalId
    principalType: 'ServicePrincipal'
  }
}
resource keyRead 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(vault.id, automation.id, 'key')
  scope: vault
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')
    principalId: automation.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

output automationAccountId string = automation.id
output automationPrincipalId string = automation.identity.principalId
output stateAccountName string = storage.name
output keyVaultName string = vault.name
output runtimeEnvironmentName string = runtime.name
output configuration object = runnerConfig
// Operator publication creates/verifies schedules. Incremental deployment never deletes the old VM.
output scheduleNames array = ['SydneyDaily0030', 'PublishingRecovery0130', 'PublishingDeadline0630']

Authentication, dispatch and recovery

The common file is prepended to each of the three entrypoints by the publisher. Preserve their relative paths and compatibility names. The controller uses schema 3 with github-hosted execution, one real Sydney date and only the exact due workflow/ref set.

Automations\runbooks\Runner.Common.ps1

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
# Prepended to each published runbook by Publish-RunnerRunbooks.ps1.
$ErrorActionPreference = 'Stop'
$script:ArmRoot = 'https://management.azure.com'
$script:AzureTokens = @{}
$script:GitHubToken = $null
$script:LeaseId = $null
$script:ReadOnlyProbe = $false

function Get-SydneyZone {
    try { return [TimeZoneInfo]::FindSystemTimeZoneById('Australia/Sydney') }
    catch [TimeZoneNotFoundException] {
        # Windows PowerShell's .NET Framework only understands Windows zone IDs.
        return [TimeZoneInfo]::FindSystemTimeZoneById('AUS Eastern Standard Time')
    }
}

function Get-SydneyWindow {
    param([datetimeoffset]$Now = [datetimeoffset]::UtcNow)
    $zone = Get-SydneyZone
    $local = [TimeZoneInfo]::ConvertTime($Now, $zone)
    $day = [datetime]::SpecifyKind($local.Date, [DateTimeKind]::Unspecified)
    $start = [TimeZoneInfo]::ConvertTimeToUtc($day.AddMinutes(30), $zone)
    $warning = [TimeZoneInfo]::ConvertTimeToUtc($day.AddHours(6).AddMinutes(30), $zone)
    $hard = [TimeZoneInfo]::ConvertTimeToUtc($day.AddHours(7), $zone)
    return @{
        date = $day.ToString('yyyy-MM-dd')
        start = [datetimeoffset]$start
        warning = [datetimeoffset]$warning
        hard = [datetimeoffset]$hard
    }
}

function ConvertFrom-RunnerConfig {
    param($Value)
    for ($depth = 0; $depth -lt 2 -and $Value -is [string]; $depth++) {
        try { $Value = ConvertFrom-Json -InputObject $Value -NoEnumerate -ErrorAction Stop }
        catch { throw 'RunnerConfig contains invalid JSON.' }
    }
    if ($Value -is [System.Collections.IDictionary]) { return [pscustomobject]$Value }
    if ($null -eq $Value -or $Value.GetType() -ne [System.Management.Automation.PSCustomObject]) {
        throw 'RunnerConfig must decode to an object within two JSON layers.'
    }
    return $Value
}

function Initialize-Runner {
    $raw = Get-AutomationVariable -Name 'RunnerConfig'
    $script:Config = ConvertFrom-RunnerConfig $raw
    if ($script:Config.organization -ne 'ExamplePublishing' -or
        $script:Config.workflows.Count -ne 2 -or
        (@($script:Config.workflows.repository | Sort-Object) -join ',') -ne 'ai-strategy-blog,blog') {
        throw 'RunnerConfig does not match the two-repository contract.'
    }
    $script:StateUrl = "https://$($script:Config.storageAccount).blob.core.windows.net/$($script:Config.stateContainer)/current.json"
    foreach ($workflow in $script:Config.workflows) {
        $days = $workflow.publishDays
        if ($days -isnot [array] -or $days.Count -eq 0 -or
            @($days | Where-Object { $_ -isnot [string] -or $_ -cnotin [enum]::GetNames([DayOfWeek]) }).Count -gt 0 -or
            @($days | Sort-Object -Unique).Count -ne $days.Count) {
            throw "$($workflow.repository): publishDays must be a nonempty array of unique canonical weekday names."
        }
    }
    if (-not $script:Config.alertReceiver) {
        Write-Warning 'No alert receiver configured. Automation job warnings/errors require operator monitoring; activation requires an Azure Monitor action group.'
    }
}

function Get-AzureBearer {
    param([string]$Resource)
    $cached = $script:AzureTokens[$Resource]
    if ($cached -and $cached.expires -gt [datetimeoffset]::UtcNow.AddMinutes(5)) {
        return $cached.token
    }
    if (-not $env:IDENTITY_ENDPOINT -or -not $env:IDENTITY_HEADER) {
        throw 'Azure Automation managed identity endpoint is unavailable.'
    }
    $uri = "$($env:IDENTITY_ENDPOINT)?resource=$([uri]::EscapeDataString($Resource))&api-version=2019-08-01"
    $token = Invoke-RestMethod -Uri $uri -Headers @{
        'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER
        Metadata = 'True'
    } -TimeoutSec 20
    $expiry = if ([string]$token.expires_on -match '^\d+$') {
        [datetimeoffset]::FromUnixTimeSeconds([long]$token.expires_on)
    } else { [datetimeoffset]::Parse($token.expires_on) }
    $script:AzureTokens[$Resource] = @{ token = $token.access_token; expires = $expiry }
    return $token.access_token
}

function Invoke-Azure {
    param([string]$Uri, [string]$Method = 'GET', $Body = $null,
        [string]$Resource = 'https://management.azure.com/', [hashtable]$Headers = @{})
    if ($script:ReadOnlyProbe -and $Method -ne 'GET') { throw 'Read-only probe forbids Azure mutations.' }
    $headersCopy = @{
        Authorization = "Bearer $(Get-AzureBearer $Resource)"
        'Content-Type' = 'application/json'
    }
    foreach ($key in $Headers.Keys) { $headersCopy[$key] = $Headers[$key] }
    $arguments = @{ Uri = $Uri; Method = $Method; Headers = $headersCopy; TimeoutSec = 30; UseBasicParsing = $true }
    if ($null -ne $Body) { $arguments.Body = $Body | ConvertTo-Json -Depth 30 -Compress }
    try {
        $response = Invoke-WebRequest @arguments
        if ($response.Content) { return ($response.Content | ConvertFrom-Json) }
    } catch {
        # Never include response bodies: identity/Key Vault errors can contain sensitive material.
        $status = 0
        if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
        throw "Azure $Method failed (HTTP $status). No automatic retry of a state-changing request."
    }
}

function Invoke-StateRequest {
    param([string]$Method, [string]$Suffix = '', $Body = $null, [hashtable]$Headers = @{})
    if ($script:ReadOnlyProbe -and $Method -ne 'GET') { throw 'Read-only probe forbids state or lease mutations.' }
    $allHeaders = @{
        Authorization = "Bearer $(Get-AzureBearer 'https://storage.azure.com/')"
        'x-ms-version' = '2023-11-03'
        'x-ms-date' = [datetime]::UtcNow.ToString('R')
    }
    foreach ($key in $Headers.Keys) { $allHeaders[$key] = $Headers[$key] }
    $arguments = @{
        Uri = "$script:StateUrl$Suffix"; Method = $Method; Headers = $allHeaders
        TimeoutSec = 10; UseBasicParsing = $true
    }
    if ($null -ne $Body) {
        $arguments.Body = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Depth 30 -Compress))
        $arguments.ContentType = 'application/json'
    }
    # Callers need 404/409/412 to distinguish missing state and competing leases.
    return Invoke-WebRequest @arguments
}

function Get-HttpStatus {
    param($Failure)
    if ($Failure.Exception.Response) { return [int]$Failure.Exception.Response.StatusCode }
    return 0
}

function Read-Cycle {
    try {
        $response = Invoke-StateRequest GET
        return ($response.Content | ConvertFrom-Json)
    } catch {
        if ((Get-HttpStatus $_) -eq 404) { return $null }
        throw 'Durable cycle read failed; no in-memory fallback is allowed.'
    }
}

function Enter-CycleLease {
    if ($script:ReadOnlyProbe) { throw 'Read-only probe forbids lease acquisition.' }
    # The same blob holds both the lease and durable state; no separate lock can drift.
    try {
        $null = Invoke-StateRequest PUT -Body @{ schema = 3; phase = 'idle' } -Headers @{
            'x-ms-blob-type' = 'BlockBlob'; 'If-None-Match' = '*'
        }
    } catch {
        if ((Get-HttpStatus $_) -notin @(409, 412)) { throw 'Cannot initialize durable cycle blob.' }
    }
    try {
        $response = Invoke-StateRequest PUT '?comp=lease' -Headers @{
            'x-ms-lease-action' = 'acquire'; 'x-ms-lease-duration' = '60'
        }
        $script:LeaseId = [string]$response.Headers['x-ms-lease-id']
        if (-not $script:LeaseId) { throw 'Storage did not return a lease ID.' }
        return $true
    } catch {
        if ((Get-HttpStatus $_) -eq 409) { return $false }
        throw 'Cannot acquire cycle lease.'
    }
}

function Update-CycleLease {
    if ($script:ReadOnlyProbe) { throw 'Read-only probe forbids lease renewal.' }
    if (-not $script:LeaseId) { throw 'Cycle mutation requires a lease.' }
    try {
        $null = Invoke-StateRequest PUT '?comp=lease' -Headers @{
            'x-ms-lease-action' = 'renew'; 'x-ms-lease-id' = $script:LeaseId
        }
    } catch { throw 'Lost cycle lease; refusing further dispatch or state mutation.' }
}

function Write-Cycle {
    param($Cycle)
    if ($script:ReadOnlyProbe) { throw 'Read-only probe forbids cycle mutations.' }
    Update-CycleLease
    $Cycle.updatedUtc = [datetimeoffset]::UtcNow.ToString('o')
    try {
        $null = Invoke-StateRequest PUT -Body $Cycle -Headers @{
            'x-ms-blob-type' = 'BlockBlob'; 'x-ms-lease-id' = $script:LeaseId
        }
    } catch { throw 'Durable cycle write failed; refusing to continue.' }
}

function Exit-CycleLease {
    if ($script:ReadOnlyProbe) { throw 'Read-only probe forbids lease release.' }
    if ($script:LeaseId) {
        try {
            $null = Invoke-StateRequest PUT '?comp=lease' -Headers @{
                'x-ms-lease-action' = 'release'; 'x-ms-lease-id' = $script:LeaseId
            }
        } catch { Write-Warning 'Lease release failed; the 60-second lease will expire.' }
        $script:LeaseId = $null
    }
}

function ConvertTo-Base64Url {
    param([byte[]]$Bytes)
    return [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}

function Get-GitHubBearer {
    if ($script:GitHubToken -and $script:GitHubToken.expires -gt [datetimeoffset]::UtcNow.AddMinutes(5)) {
        return $script:GitHubToken.token
    }
    if (-not $script:Config.githubAppId -or -not $script:Config.githubInstallationId) {
        throw 'GitHub App onboarding incomplete: App ID and installation ID are required. Interactive GH_TOKEN is never used.'
    }
    $key = Invoke-Azure -Uri "https://$($script:Config.vaultName).vault.azure.net/secrets/$($script:Config.githubKeySecretName)?api-version=7.4" -Resource 'https://vault.azure.net'
    if (-not $script:ReadOnlyProbe) { Update-CycleLease }
    $rsa = [Security.Cryptography.RSA]::Create()
    try {
        $rsa.ImportFromPem($key.value)
        $now = [datetimeoffset]::UtcNow.ToUnixTimeSeconds()
        $header = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes('{"alg":"RS256","typ":"JWT"}'))
        $payload = @{ iat = $now - 60; exp = $now + 540; iss = $script:Config.githubAppId } | ConvertTo-Json -Compress
        $unsigned = "$header.$(ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($payload)))"
        $signature = $rsa.SignData([Text.Encoding]::UTF8.GetBytes($unsigned),
            [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1)
        $jwt = "$unsigned.$(ConvertTo-Base64Url $signature)"
    } finally { $rsa.Dispose(); $key = $null }
    try {
        $token = Invoke-RestMethod -Method POST `
            -Uri "https://api.github.com/app/installations/$($script:Config.githubInstallationId)/access_tokens" `
            -Headers @{ Authorization = "Bearer $jwt"; Accept = 'application/vnd.github+json'; 'X-GitHub-Api-Version' = '2022-11-28' } `
            -ContentType 'application/json' -Body '{}' -TimeoutSec 20
    } catch { throw 'GitHub App installation-token creation failed. Check installation and permissions; no PAT fallback.' }
    finally { $jwt = $null }
    $script:GitHubToken = @{ token = $token.token; expires = [datetimeoffset]::Parse($token.expires_at) }
    return $token.token
}

function Invoke-GitHub {
    param([string]$Path, [string]$Method = 'GET', $Body = $null)
    # Each API call renews the lease; token minting is bounded separately below.
    if ($script:ReadOnlyProbe -and $Method -ne 'GET') { throw 'Read-only probe forbids GitHub mutations.' }
    if (-not $script:ReadOnlyProbe) { Update-CycleLease }
    $bearer = Get-GitHubBearer
    if (-not $script:ReadOnlyProbe) { Update-CycleLease }
    if ($Method -eq 'POST' -and $Path.EndsWith('/dispatches')) {
        $window = Get-SydneyWindow
        if ([datetimeoffset]::UtcNow -lt $window.start -or [datetimeoffset]::UtcNow -ge $window.warning -or $Body.inputs.publish_date -ne $window.date) {
            throw 'Dispatch deadline/date changed during authentication; refusing a late or stale publishing request.'
        }
    }
    $arguments = @{
        Uri = "https://api.github.com$Path"; Method = $Method; TimeoutSec = 20
        Headers = @{
            Authorization = "Bearer $bearer"; Accept = 'application/vnd.github+json'
            'X-GitHub-Api-Version' = '2022-11-28'
        }
    }
    if ($null -ne $Body) { $arguments.Body = $Body | ConvertTo-Json -Depth 15 -Compress; $arguments.ContentType = 'application/json' }
    try { return Invoke-RestMethod @arguments }
    catch {
        $status = Get-HttpStatus $_
        throw "GitHub $Method failed (HTTP $status). Check permission/rate-limit status. Mutations are not automatically retried."
    }
}

function Find-CorrelatedRun {
    param($Cycle, $Target)
    $matches = @()
    $created = [uri]::EscapeDataString(">=$([datetimeoffset]::Parse($Cycle.startedUtc).AddMinutes(-5).ToString('yyyy-MM-ddTHH:mm:ssZ'))")
    $workflow = [uri]::EscapeDataString($Target.workflow)
    for ($page = 1; $page -le 5; $page++) {
        $result = Invoke-GitHub "/repos/$($script:Config.organization)/$($Target.repository)/actions/workflows/$workflow/runs?event=workflow_dispatch&branch=main&created=$created&per_page=50&page=$page"
        $matches += @($result.workflow_runs | Where-Object {
            $_.display_title -eq "Azure daily publish / $($Cycle.cycleId)" -and
            $_.event -eq 'workflow_dispatch' -and $_.head_branch -eq 'main'
        })
        if (@($result.workflow_runs).Count -lt 50) { break }
        if ($page -eq 5) { throw 'Run discovery exceeded 250 runs; refusing an incomplete correlation search.' }
    }
    if ($matches.Count -gt 1) { throw 'Duplicate correlated runs detected; operator intervention required.' }
    if ($matches.Count -eq 1) { return $matches[0] }
    return $null
}

function Get-DueWorkflows {
    param([string]$PublishDate)
    $day = [datetime]::ParseExact($PublishDate, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture)
    return @($script:Config.workflows | Where-Object { $day.DayOfWeek.ToString() -cin $_.publishDays })
}

function Write-NoSitesScheduled {
    param([string]$PublishDate)
    $day = [datetime]::ParseExact($PublishDate, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture)
    Write-Output "No sites scheduled for $PublishDate ($($day.DayOfWeek))"
}

function Test-CycleTargets {
    param($Cycle)
    $due = @(Get-DueWorkflows $Cycle.publishDate)
    $actual = @($Cycle.targets.repository | Sort-Object)
    $matches = @($Cycle.targets | Where-Object {
        $target = $_
        @($due | Where-Object {
            $_.repository -ceq $target.repository -and $_.workflow -ceq $target.workflow -and $_.ref -ceq $target.ref
        }).Count -eq 1
    })
    return $Cycle.schema -eq 3 -and $Cycle.execution -eq 'github-hosted' -and $due.Count -gt 0 -and $matches.Count -eq $due.Count -and
        $actual.Count -eq $due.Count -and
        ($actual -join ',') -ceq (@($due.repository | Sort-Object) -join ',')
}

function New-PublishingCycle {
    param($Window)
    $due = @(Get-DueWorkflows $Window.date)
    if ($due.Count -eq 0) { throw 'Cannot create a publishing cycle with no scheduled sites.' }
    $targets = @($due | ForEach-Object {
        @{
            repository = $_.repository; workflow = $_.workflow; ref = $_.ref
            stage = 'pending'; runId = $null; dispatchedUtc = $null
            status = $null; conclusion = $null
        }
    })
    return @{
        schema = 3; execution = 'github-hosted'; phase = 'running'; cycleId = [guid]::NewGuid().ToString()
        publishDate = $Window.date; startedUtc = [datetimeoffset]::UtcNow.ToString('o')
        warningUtc = $Window.warning.ToString('o'); verificationDeadlineUtc = $Window.hard.ToString('o')
        updatedUtc = $null; error = $null; targets = $targets
    }
}

function Test-AllTerminal {
    param($Cycle)
    return (Test-CycleTargets $Cycle) -and @($Cycle.targets | Where-Object {
        -not $_.runId -or $_.status -ne 'completed'
    }).Count -eq 0
}

function Test-CycleSuccess {
    param($Cycle)
    return -not $Cycle.error -and (Test-AllTerminal $Cycle) -and @($Cycle.targets | Where-Object { $_.conclusion -ne 'success' }).Count -eq 0
}

function Assert-PublishingConfiguration {
    if (-not $script:Config.githubAppId -or -not $script:Config.githubInstallationId) {
        throw 'GitHub App onboarding incomplete: App ID and installation ID are required. Interactive GH_TOKEN is never used.'
    }
    if (-not $script:Config.alertReceiver -or $script:Config.alertReceiver -notmatch
        '^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.Insights/actionGroups/[^/]+$') {
        throw 'Publishing activation blocked: verified Azure Monitor action-group receiver is required.'
    }
}

function Assert-WorkflowReadiness {
    param($Workflows = $script:Config.workflows)
    Assert-PublishingConfiguration
    foreach ($target in $Workflows) {
        if ($target.ref -ne 'main') { throw 'Only main is authorized.' }
        $workflow = Invoke-GitHub "/repos/$($script:Config.organization)/$($target.repository)/actions/workflows/$($target.workflow)"
        if ($workflow.state -ne 'active') { throw "$($target.repository): target workflow is not active." }
    }
}

function Update-PublishingCycle {
    param($Cycle)
    Update-CycleLease
    if (-not (Test-CycleTargets $Cycle)) { throw 'Cycle targets do not match the configured publishing cadence or schema.' }
    if ($Cycle.phase -eq 'finished') {
        if (-not (Test-CycleSuccess $Cycle)) { throw 'Finished publication failed verification.' }
        return
    }
    if ($Cycle.phase -ne 'running') { throw 'Unsupported persisted phase; operator recovery required.' }
    Assert-WorkflowReadiness @(Get-DueWorkflows $Cycle.publishDate)
        foreach ($target in $Cycle.targets) {
            if ($target.stage -eq 'pending') {
                $window = Get-SydneyWindow
                if ([datetimeoffset]::UtcNow -lt $window.start -or
                    [datetimeoffset]::UtcNow -ge $window.warning -or $Cycle.publishDate -ne $window.date) {
                    throw 'Undispatched site outside the authorized publishing window; no late dispatch.'
                }
                $target.stage = 'ambiguous'
                $target.dispatchedUtc = [datetimeoffset]::UtcNow.ToString('o')
                Write-Cycle $Cycle
                # Ambiguous stage is durable BEFORE the network side effect. Never redispatch.
                $null = Invoke-GitHub "/repos/$($script:Config.organization)/$($target.repository)/actions/workflows/$($target.workflow)/dispatches" POST @{
                    ref = 'main'
                    inputs = @{ cycle_id = $Cycle.cycleId; publish_date = $Cycle.publishDate }
                }
                $target.stage = 'dispatched'
                Write-Cycle $Cycle
            }
            if (-not $target.runId -and $target.stage -in @('ambiguous', 'dispatched')) {
                $run = Find-CorrelatedRun $Cycle $target
                if (-not $run) {
                    Write-Warning "Awaiting exact run correlation for $($target.repository); no dispatch retry."
                    # Sequential dispatch: discover and persist the first run before dispatching the second.
                    break
                }
                $target.runId = [long]$run.id
                $target.stage = 'known'
                Write-Cycle $Cycle
            }
            if ($target.runId) {
                $run = Invoke-GitHub "/repos/$($script:Config.organization)/$($target.repository)/actions/runs/$($target.runId)"
                if ($run.display_title -ne "Azure daily publish / $($Cycle.cycleId)" -or
                    $run.head_branch -ne 'main' -or $run.event -ne 'workflow_dispatch') {
                    throw 'Persisted run no longer matches cycle identity.'
                }
                $target.status = $run.status
                $target.conclusion = $run.conclusion
                Write-Cycle $Cycle
            }
        }
        if (Test-AllTerminal $Cycle) {
            $late = [datetimeoffset]::UtcNow -ge [datetimeoffset]::Parse($Cycle.verificationDeadlineUtc)
            $Cycle.phase = 'finished'
            $Cycle.error = if (Test-CycleSuccess $Cycle) { $null } else { 'Scheduled publishing workflows did not succeed.' }
            if ($late) { $Cycle.error = 'Publication was not verified before 07:00 Sydney; late completion is not timely success.' }
            Write-Cycle $Cycle
            if ($Cycle.error) { throw $Cycle.error }
            Write-Output "Verified hosted publication: $($Cycle.cycleId)."
        }
        if ($Cycle.phase -ne 'finished' -and [datetimeoffset]::UtcNow -ge [datetimeoffset]::Parse($Cycle.verificationDeadlineUtc)) {
            $Cycle.error = 'No verified successful publication by 07:00 Sydney; hosted runs are not cancelled.'
            Write-Cycle $Cycle
            throw $Cycle.error
        }
}

function Invoke-PublishingJob {
    param([switch]$Recovery)
    $window = Get-SydneyWindow
    if (@(Get-DueWorkflows $window.date).Count -eq 0) { return }
    $now = [datetimeoffset]::UtcNow
    if ($now -lt $window.start) { throw 'Publishing cannot start before 00:30 Sydney.' }
    if (-not (Enter-CycleLease)) { throw 'Controller lease held; another job owns publication.' }
    try {
        $cycle = Read-Cycle
        $idle = $null -eq $cycle -or ($cycle.schema -in @(1, 2, 3) -and
            $cycle.phase -eq 'idle' -and -not $cycle.publishDate -and -not $cycle.targets)
        if ($cycle -and $cycle.phase -eq 'idle' -and -not $idle) { throw 'Malformed or unsupported idle state.' }
        if (-not $idle -and $cycle.publishDate -ne $window.date -and $cycle.phase -ne 'finished') {
            throw 'Previous cycle unresolved; refusing to overwrite or redispatch.'
        }
        if ($idle -or $cycle.publishDate -ne $window.date) {
            if ($now -ge $window.warning) { throw "No verified successful publishing cycle for $($window.date) by 06:30 Sydney." }
            Assert-WorkflowReadiness @(Get-DueWorkflows $window.date)
            $cycle = New-PublishingCycle $window
            Write-Cycle $cycle
        }
        $end = $now.AddMinutes(15)
        if ($Recovery -and $now -ge $window.warning -and $now -lt $window.hard) { $end = $window.hard }
        do {
            Update-PublishingCycle $cycle
            if ($cycle.phase -eq 'finished') { return }
            if ([datetimeoffset]::UtcNow -ge $end) { break }
            # Renew during waits; never sleep for the full 60-second lease duration.
            Update-CycleLease
            Start-Sleep -Seconds 20
        } while ($true)
        if ([datetimeoffset]::UtcNow -ge $window.hard) { throw 'No verified successful publication by 07:00 Sydney.' }
        Write-Warning 'Bounded polling ended; the recovery/deadline job must verify persisted runs.'
    } finally { Exit-CycleLease }
}

Automations\runbooks\Start-DailyPublishing.ps1

1
2
Initialize-Runner
Invoke-PublishingJob

Automations\runbooks\Stop-RunnerWatchdog.ps1

1
2
3
# Name retained for existing monitoring and operator integration.
Initialize-Runner
Invoke-PublishingJob -Recovery

Automations\runbooks\Test-RunnerReadiness.ps1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Safe manual probe at any time: no durable writes or workflow dispatch.
Initialize-Runner
Assert-PublishingConfiguration
$script:ReadOnlyProbe = $true
try {
$cycle = Read-Cycle
if ($null -eq $cycle) {
    Write-Output 'Persisted state: absent; no cycle created.'
} else {
    $targetCount = @($cycle.targets | Where-Object { $null -ne $_ }).Count
    Write-Output "Persisted state: schema=$($cycle.schema); phase=$($cycle.phase); publishDate=$($cycle.publishDate); targetCount=$targetCount."
    if ($cycle.phase -notin @('idle', 'finished')) { throw 'Readiness refused: active persisted cycle requires watchdog/operator recovery.' }
    $idle = $cycle.phase -eq 'idle' -and -not $cycle.publishDate -and $targetCount -eq 0
    if ($cycle.schema -notin @(1, 2, 3) -or ($cycle.schema -ne 3 -and -not $idle)) {
        throw 'Readiness refused: unsupported legacy persisted state; do not relabel historical deployments.'
    }
    if ($cycle.phase -eq 'idle' -and -not $idle) { throw 'Readiness refused: malformed idle state.' }
    if ($cycle.phase -eq 'finished' -and -not (Test-CycleTargets $cycle)) {
        throw 'Readiness refused: persisted target set does not match cadence.'
    }
}
Assert-WorkflowReadiness
Write-Output 'App authentication and hosted publishing workflows verified. Read-only state inspection; no lease or dispatch performed.'
} finally { $script:ReadOnlyProbe = $false }

The production runbooks were published byte-identical to source after LF normalization. The managed-identity readiness probe completed before and after VM retirement, leaving the schema-1 idle ledger unchanged. It took no lease, wrote nothing and dispatched nothing.

Completion and missed-publication alerts

This complete template creates a failed-job metric rule and a due-day absence log rule. The publisher derives dueWeekdays from RunnerConfig; it is not an independent cadence source. Keep heartbeatStartUtc stable on updates.

Automations\infra\runner-alerts.bicep

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
targetScope = 'resourceGroup'

param automationName string = 'aa-gh-publishing'
@description('Required existing Log Analytics workspace resource ID. Azure Automation diagnostics use billable ingestion.')
param workspaceResourceId string
@description('Log Analytics workspace region.')
param workspaceLocation string
@description('The approved action group. Existing selected group in rg-management.')
param actionGroupResourceId string = '/subscriptions/<YOUR-SUBSCRIPTION-ID>/resourceGroups/rg-management/providers/Microsoft.Insights/actionGroups/<YOUR-ACTION-GROUP-NAME>'
@description('Enable only after workspace tables and action-group delivery have been verified.')
param monitoringEnabled bool = false
@description('Collect job logs independently so ingestion can be verified before enabling alerts.')
param diagnosticsEnabled bool = monitoringEnabled
@description('Activation time; keep stable on updates.')
param heartbeatStartUtc string = utcNow('u')
@description('Derived union of RunnerConfig publishDays, never maintained as a second cadence source.')
param dueWeekdays array = ['Tuesday', 'Wednesday', 'Thursday', 'Friday']

var automationId = resourceId('Microsoft.Automation/automationAccounts', automationName)

resource automation 'Microsoft.Automation/automationAccounts@2024-10-23' existing = {
  name: automationName
}

resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
  name: 'publishing-job-monitoring'
  scope: automation
  properties: {
    workspaceId: workspaceResourceId
    logs: [
      { category: 'JobLogs', enabled: diagnosticsEnabled }
    ]
    metrics: []
  }
}

var watchdogHeartbeatQuery = '''
let localNow = datetime_utc_to_local(now(), 'Australia/Sydney');
let localDay = startofday(localNow);
let weekday = tostring(dynamic(["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"])[toint(dayofweek(localNow) / 1d)]);
let ranToday = toscalar(
    AzureDiagnostics
    | where TimeGenerated > ago(24h)
    | where ResourceId =~ '{0}'
    | where ResourceProvider == "MICROSOFT.AUTOMATION"
    | where Category == "JobLogs"
    | where RunbookName_s == "Stop-RunnerWatchdog"
    | where ResultType == "Completed"
    | where datetime_utc_to_local(TimeGenerated, 'Australia/Sydney') >= localDay + 6h + 30m
    | summarize count());
print MissingHeartbeat = toint(now() >= datetime('{1}') and set_has_element(dynamic({2}), weekday) and localNow >= localDay + 7h + 15m and ranToday == 0)
'''

resource failedJobsAlert 'Microsoft.Insights/metricAlerts@2018-03-01' = {
  name: 'publishing-automation-failures'
  location: 'global'
  properties: {
    description: 'Failed/stopped/suspended publishing controller jobs.'
    enabled: monitoringEnabled
    severity: 2
    evaluationFrequency: 'PT5M'
    windowSize: 'PT15M'
    scopes: [automation.id]
    autoMitigate: true
    criteria: {
      'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria'
      allOf: [
        {
          name: 'FailedPublishingJob'
          metricName: 'TotalJob'
          metricNamespace: 'Microsoft.Automation/automationAccounts'
          timeAggregation: 'Total'
          operator: 'GreaterThan'
          threshold: 0
          criterionType: 'StaticThresholdCriterion'
          skipMetricValidation: false
          dimensions: [
            { name: 'Runbook', operator: 'Include', values: ['Start-DailyPublishing', 'Stop-RunnerWatchdog'] }
            { name: 'Status', operator: 'Include', values: ['Failed', 'Stopped', 'Suspended'] }
          ]
        }
      ]
    }
    actions: [{ actionGroupId: actionGroupResourceId }]
  }
}

resource missingWatchdogAlert 'Microsoft.Insights/scheduledQueryRules@2023-12-01' = {
  name: 'publishing-watchdog-missed'
  location: workspaceLocation
  kind: 'LogAlert'
  properties: {
    displayName: 'Due-day publishing Automation absent'
    description: 'After 07:15 Sydney on a due day, no completed deadline verification job was ingested. Cadence derived from RunnerConfig.'
    enabled: monitoringEnabled
    severity: 1
    evaluationFrequency: 'PT1H'
    windowSize: 'P1D'
    scopes: [workspaceResourceId]
    autoMitigate: true
    checkWorkspaceAlertsStorageConfigured: false
    skipQueryValidation: true
    criteria: {
      allOf: [
        {
          query: format(watchdogHeartbeatQuery, automationId, heartbeatStartUtc, string(dueWeekdays))
          timeAggregation: 'Maximum'
          metricMeasureColumn: 'MissingHeartbeat'
          operator: 'GreaterThan'
          threshold: 0
          failingPeriods: { numberOfEvaluationPeriods: 1, minFailingPeriodsToAlert: 1 }
        }
      ]
    }
    actions: { actionGroups: [actionGroupResourceId] }
  }
}

output diagnosticSettingId string = diagnostics.id
output failureAlertId string = failedJobsAlert.id
output watchdogMissedAlertId string = missingWatchdogAlert.id

Two alerts are enabled: a controller-failure alert and an hourly due-day absence alert. The absence query was tested against actual Log Analytics data: a due Tuesday at 08:00 without verification returned 1, while Sunday, Monday and before-grace cases returned 0. Those tests establish query behavior, not email receipt. No VM shutdown watchdog or VM-power-state alert remains.

Hugo workflow and publication guards

Save these files in the existing Hugo repository. Every job uses ubuntu-latest; a nonempty cycle ID invokes Azure metadata admission on main. The guard hashes built HTML and verifies matching public bytes. The existing Static Web Apps secret name is an identifier, not a credential value; configure that named secret with your own token.

blog\.github\workflows\azure-static-web-apps-red-flower-083307e00.yml

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144

name: Azure Static Web Apps CI/CD
run-name: ${{ inputs.cycle_id != '' && format('Azure daily publish / {0}', inputs.cycle_id) || format('{0} / {1}', github.event_name, github.head_ref || github.ref_name) }}

on:
  workflow_dispatch:
    inputs:
      cycle_id:
        description: Azure controller cycle UUID
        type: string
        required: false
        default: ''
      publish_date:
        description: Azure controller publication date (yyyy-MM-dd, Australia/Sydney)
        type: string
        required: false
        default: ''
  schedule:
    # 14:17 UTC = 1:17 AM Sydney during AEDT, 12:17 AM during AEST; allows delay before 7 AM.
    - cron: '17 14 * * *'
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
    types: [ opened, synchronize, reopened, closed ]

permissions:
  contents: read
  pull-requests: write
  id-token: write
  deployments: write

concurrency:
  group: ${{ github.event_name == 'pull_request' && format('swa-pr-{0}-{1}', github.event.pull_request.number, github.event.action == 'closed' && 'cleanup' || 'preview') || 'swa-production' }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  admit_publish:
    if: github.event_name == 'push' || (github.event_name == 'schedule' && vars.AZURE_DAILY_PUBLISHING_ENABLED != 'true') || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - name: Validate publication metadata
        env:
          CYCLE_ID: ${{ inputs.cycle_id }}
          PUBLISH_DATE: ${{ inputs.publish_date }}
        run: node scripts/publication-checks.mjs admit

  build_and_deploy_job:
    needs: admit_publish
    runs-on: ubuntu-latest
    timeout-minutes: 30
    name: Build and Deploy
    env:
      CYCLE_ID: ${{ inputs.cycle_id }}
      PUBLISH_DATE: ${{ inputs.publish_date }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: true
          lfs: false

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Revalidate dispatch after runner queue wait
        run: |
          node scripts/publication-checks.mjs admit
          echo "PUBLICATION_MANIFEST=$RUNNER_TEMP/publication-manifest.json" >> "$GITHUB_ENV"

      - name: Cache Hugo resources
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/hugo
            resources
          key: ${{ runner.os }}-hugo-${{ hashFiles('**/*.toml', '**/*.yaml', '**/*.yml', '**/*.json', 'themes/**', 'config.*') }}
          restore-keys: |
            ${{ runner.os }}-hugo-

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.152.2'
          extended: true

      - name: Setup Go for Hugo modules
        uses: actions/setup-go@v5
        with:
          go-version: '1.24.x'
          cache: false

      - name: Test publication guards
        run: npm test

      - name: Resolve publication metadata with Hugo
        run: node scripts/publication-checks.mjs manifest

      - name: Build site
        run: |
          rm -rf -- public
          hugo --minify

      - name: Validate due pages and future exclusions before upload
        run: node scripts/publication-checks.mjs predeploy

      - name: Deploy to Azure Static Web Apps (upload prebuilt)
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_RED_FLOWER_083307E00 }}
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          action: "upload"

          # IMPORTANT: we are uploading the prebuilt output
          app_location: "public"     # <- point to the built folder
          output_location: ""        # <- no further output folder inside 'public'
          skip_app_build: true       # <- prevents Oryx from building

          # No API in this project
          api_location: ""
          skip_api_build: true

      - name: Verify public production pages
        if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
        run: node scripts/publication-checks.mjs postdeploy

  close_pull_request_job:
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    name: Close Pull Request
    steps:
      - name: Close Pull Request
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_RED_FLOWER_083307E00 }}
          action: "close"

blog\scripts\publication-checks.mjs

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { setTimeout as sleep } from 'node:timers/promises';

const origin = 'https://blog.example.com';
const zeroDate = '0001-01-01T00:00:00Z';

export function sydneyDate(now = new Date()) {
  const parts = new Intl.DateTimeFormat('en-AU', {
    timeZone: 'Australia/Sydney', year: 'numeric', month: '2-digit', day: '2-digit',
  }).formatToParts(now);
  const part = (type) => parts.find((item) => item.type === type).value;
  return `${part('year')}-${part('month')}-${part('day')}`;
}

export function admit(env, now = new Date()) {
  if (!env.CYCLE_ID) return;
  assert.equal(env.GITHUB_EVENT_NAME, 'workflow_dispatch', 'Azure requires workflow_dispatch');
  assert.equal(env.GITHUB_REF, 'refs/heads/main', 'Azure requires main');
  assert.match(env.CYCLE_ID || '', /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, 'cycle_id must be a UUID');
  assert.match(env.PUBLISH_DATE || '', /^\d{4}-\d{2}-\d{2}$/, 'publish_date must be yyyy-MM-dd');
  assert.equal(env.PUBLISH_DATE, sydneyDate(now), 'publish_date must be the real current Sydney date');
}

export function parseHugoCSV(text) {
  const rows = [];
  let row = [], field = '', quoted = false;
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    if (ch === '"') {
      if (quoted && text[i + 1] === '"') { field += '"'; i++; }
      else quoted = !quoted;
    } else if (!quoted && (ch === ',' || ch === '\n')) {
      row.push(field.replace(/\r$/, ''));
      field = '';
      if (ch === '\n') { rows.push(row); row = []; }
    } else field += ch;
  }
  assert(!quoted, 'Unclosed Hugo CSV quote');
  if (field || row.length) { row.push(field.replace(/\r$/, '')); rows.push(row); }
  const headers = rows.shift();
  for (const required of ['path', 'publishDate', 'expiryDate', 'draft', 'permalink', 'kind']) {
    assert(headers?.includes(required), `Missing Hugo CSV column: ${required}`);
  }
  return rows.map((values) => {
    assert.equal(values.length, headers.length, 'Malformed Hugo CSV record');
    return Object.fromEntries(headers.map((header, index) => [header, values[index]]));
  });
}

export function listPublicationPages(mode, { hugo = 'hugo', run = execFileSync } = {}) {
  assert(['all', 'published'].includes(mode), 'Invalid Hugo publication list mode');
  let output;
  try {
    output = run(hugo, [
      'list', mode, '--environment', 'production',
    ], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
  } catch (error) {
    for (const stream of ['stdout', 'stderr']) {
      if (error[stream]?.length) console.error(`Hugo ${stream}:\n${error[stream].toString().trimEnd()}`);
    }
    throw error;
  }
  // Cold module collection can prepend progress to Hugo 0.152.2's CSV stdout.
  const progress = output.match(/^(?:hugo: downloading modules \u2026\r?\n|hugo: collected modules in \d+ ms(?:\r?\n)?)/);
  if (progress) console.error(progress[0].trim());
  const csv = progress ? output.slice(progress[0].length) : output;
  assert(csv.startsWith('path,slug,title,date,expiryDate,publishDate,draft,permalink,kind,section'), 'Unexpected Hugo publication CSV header');
  return parseHugoCSV(csv);
}

function dateValue(value) {
  const time = Date.parse(value);
  assert(Number.isFinite(time), `Invalid Hugo date: ${value}`);
  return time;
}

export function outputFile(permalink, outputDir) {
  const url = new URL(permalink);
  assert.equal(url.origin, origin, `Unexpected publication origin: ${permalink}`);
  assert(!url.search && !url.hash, `Unsupported publication URL: ${permalink}`);
  const pathname = decodeURIComponent(url.pathname);
  assert(!pathname.includes('\\') && !pathname.includes('\0'), 'Unsafe publication path');
  const filename = pathname.endsWith('/') ? `${pathname}index.html` : pathname;
  assert(filename.endsWith('.html'), `Expected HTML permalink: ${permalink}`);
  const root = path.resolve(outputDir);
  const result = path.resolve(root, `.${filename}`);
  assert(result.startsWith(`${root}${path.sep}`), 'Publication path escapes output directory');
  return result;
}

export function createManifest(all, published, now = new Date()) {
  const included = new Set(published.map((page) => page.path));
  const due = [], excluded = [];
  for (const page of all) {
    // Non-rendering/headless pages have no permalink; dates and URLs come from Hugo, not filenames.
    if (page.kind !== 'page' || !page.permalink) continue;
    outputFile(page.permalink, 'public');
    const publishTime = dateValue(page.publishDate);
    const future = publishTime > now.getTime();
    const expired = page.expiryDate !== zeroDate && dateValue(page.expiryDate) <= now.getTime();
    assert(['true', 'false'].includes(page.draft), `Invalid draft value: ${page.path}`);
    if (future || expired || page.draft === 'true') {
      assert(!included.has(page.path), `Excluded content is published: ${page.path}`);
      excluded.push({ path: page.path, url: page.permalink });
    } else if (sydneyDate(new Date(publishTime)) === sydneyDate(now)) {
      assert(included.has(page.path), `Due-today page omitted by Hugo: ${page.path}`);
      due.push({ path: page.path, url: page.permalink });
    }
  }
  return { publishDate: sydneyDate(now), due, excluded };
}

function digest(bytes) {
  return createHash('sha256').update(bytes).digest('hex');
}

export function validateBuild(manifest, outputDir = 'public') {
  for (const page of manifest.excluded) {
    assert(!existsSync(outputFile(page.url, outputDir)), `Excluded page rendered: ${page.path}`);
  }
  const checks = manifest.due.length ? manifest.due : [{ path: 'homepage (no pages due today)', url: `${origin}/` }];
  return checks.map((page) => {
    const bytes = readFileSync(outputFile(page.url, outputDir));
    assert(bytes.length > 0, `Empty output: ${page.path}`);
    return { ...page, sha256: digest(bytes) };
  });
}

export async function verifyPublic(checks, {
  fetcher = fetch, wait = sleep, now = Date.now, budgetMs = 180000, retryMs = 10000,
} = {}) {
  assert(checks.length > 0, 'No public verification targets');
  const deadline = now() + budgetMs;
  let pending = checks;
  const errors = new Map();
  while (pending.length && now() < deadline) {
    const failed = await Promise.all(pending.map(async (page) => {
      const url = new URL(page.url);
      url.searchParams.set('publication_check', page.sha256);
      try {
        const response = await fetcher(url, {
          redirect: 'error', cache: 'no-store',
          signal: AbortSignal.timeout(Math.max(1, Math.min(15000, deadline - now()))),
        });
        assert.equal(response.status, 200, `HTTP ${response.status}`);
        assert.equal(digest(Buffer.from(await response.arrayBuffer())), page.sha256, 'Public bytes do not match this build');
        errors.delete(page.url);
        return null;
      } catch (error) {
        errors.set(page.url, error.message);
        return page;
      }
    }));
    pending = failed.filter(Boolean);
    if (pending.length && now() < deadline) await wait(Math.min(retryMs, deadline - now()));
  }
  assert.equal(pending.length, 0, `Public verification failed: ${JSON.stringify([...errors])}`);
}

async function main() {
  const [phase] = process.argv.slice(2);
  const env = process.env;
  if (phase === 'admit') {
    admit(env);
    console.log(env.CYCLE_ID ? 'Validated Azure publication metadata' : 'Admitted standard publication');
    return;
  }
  assert(['manifest', 'predeploy', 'postdeploy'].includes(phase), 'Use admit, manifest, predeploy or postdeploy');
  admit(env);
  assert(env.PUBLICATION_MANIFEST, 'PUBLICATION_MANIFEST is required');
  if (phase === 'manifest') {
    const all = listPublicationPages('all', { hugo: env.HUGO_BIN || 'hugo' });
    const published = listPublicationPages('published', { hugo: env.HUGO_BIN || 'hugo' });
    const manifest = createManifest(all, published);
    writeFileSync(env.PUBLICATION_MANIFEST, JSON.stringify(manifest, null, 2));
    console.log(`Sydney ${manifest.publishDate}: ${manifest.due.length} due page(s), ${manifest.excluded.length} excluded page(s)`);
    return;
  }
  const manifest = JSON.parse(readFileSync(env.PUBLICATION_MANIFEST, 'utf8'));
  assert.equal(manifest.publishDate, sydneyDate(), 'Sydney date changed during publication; start a new cycle');
  if (phase === 'predeploy') {
    manifest.checks = validateBuild(manifest);
    writeFileSync(env.PUBLICATION_MANIFEST, JSON.stringify(manifest, null, 2));
    console.log(`Validated ${manifest.due.length} due page(s); ${manifest.excluded.length} exclusions. ${manifest.due.length ? 'Checking due URLs after deploy.' : 'No pages due: checking homepage bytes after deploy.'}`);
  } else {
    assert(Array.isArray(manifest.checks), 'Predeploy evidence is missing');
    await verifyPublic(manifest.checks);
    console.log(`Verified ${manifest.checks.length} public URL(s) match this build`);
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
  main().catch((error) => {
    console.error(`Publication check failed: ${error.message}`);
    process.exitCode = 1;
  });
}

blog\scripts\publication-checks.test.mjs

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
  admit, createManifest, listPublicationPages, outputFile, parseHugoCSV, sydneyDate, validateBuild, verifyPublic,
} from './publication-checks.mjs';

const now = new Date('2026-11-08T02:00:00Z');
const url = 'https://blog.example.com/p/due/';
const azure = {
  GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: 'refs/heads/main',
  CYCLE_ID: 'e5db2955-dcb3-411a-9512-19e30e3b29b8', PUBLISH_DATE: '2026-11-08',
};
const page = {
  path: 'content/post/due.md', kind: 'page', permalink: url, draft: 'false',
  publishDate: '2026-11-08T00:00:00+11:00', expiryDate: '0001-01-01T00:00:00Z',
};

test('workflow uses only hosted runners and cycle-based Azure correlation', () => {
  const workflow = readFileSync(new URL('../.github/workflows/azure-static-web-apps-red-flower-083307e00.yml', import.meta.url), 'utf8');
  const admission = workflow.split('  admit_publish:')[1].split('  build_and_deploy_job:')[0];
  assert.equal((workflow.match(/runs-on: ubuntu-latest/g) || []).length, 3);
  assert(!/runner_target|RUNNER_TARGET|AZURE_RUNNER_ENABLED|self-hosted|fromJSON|publishing-sites/.test(workflow));
  assert(workflow.includes(`inputs.cycle_id != '' && format('Azure daily publish / {0}', inputs.cycle_id)`));
  assert(workflow.includes("vars.AZURE_DAILY_PUBLISHING_ENABLED != 'true'"));
  assert.equal((workflow.match(/required: false/g) || []).length, 2);
  assert(workflow.includes('run: npm test'));
  assert(admission.indexOf('actions/setup-node@v4') < admission.indexOf('node scripts/publication-checks.mjs admit'));
  assert(admission.includes("node-version: '22'"));
  assert(workflow.includes('needs: admit_publish'));
  assert(workflow.indexOf('actions/setup-go@v5') < workflow.indexOf('node scripts/publication-checks.mjs manifest'));
  assert(workflow.includes("go-version: '1.24.x'"));
});

test('standard runs and manual recovery bypass Azure metadata, cycle dispatches validate it', () => {
  assert.doesNotThrow(() => admit({}, now));
  assert.doesNotThrow(() => admit({ GITHUB_EVENT_NAME: 'workflow_dispatch', CYCLE_ID: '', PUBLISH_DATE: '' }, now));
  assert.doesNotThrow(() => admit(azure, now));
  for (const override of [
    { GITHUB_EVENT_NAME: 'pull_request' }, { GITHUB_EVENT_NAME: 'schedule' },
    { GITHUB_REF: 'refs/heads/feature' },
    { CYCLE_ID: 'not-a-uuid' }, { CYCLE_ID: '00000000-0000-0000-0000-000000000000' },
    { PUBLISH_DATE: '2026-11-07' }, { PUBLISH_DATE: '2026-02-30' },
    { PUBLISH_DATE: '08/11/2026' },
  ]) assert.throws(() => admit({ ...azure, ...override }, now));
  assert.throws(() => admit(azure, new Date('2026-11-08T13:00:00Z')), /current Sydney date/);
});

test('Sydney midnight boundaries use both daylight and standard time', () => {
  assert.equal(sydneyDate(new Date('2026-11-07T12:59:59Z')), '2026-11-07');
  assert.equal(sydneyDate(new Date('2026-11-07T13:00:00Z')), '2026-11-08');
  assert.equal(sydneyDate(new Date('2026-07-07T13:59:59Z')), '2026-07-07');
  assert.equal(sydneyDate(new Date('2026-07-07T14:00:00Z')), '2026-07-08');
});

test('CSV handles quoted commas, quotes, multiline titles and CRLF', () => {
  const csv = 'path,publishDate,expiryDate,draft,permalink,kind,title\r\n' +
    `post.md,${page.publishDate},${page.expiryDate},false,${url},page,"A, ""quoted""\ntitle"\r\n`;
  assert.equal(parseHugoCSV(csv)[0].title, 'A, "quoted"\ntitle');
  assert.throws(() => parseHugoCSV('path,title\nfile,title\n'), /Missing Hugo CSV column/);
  assert.throws(() => parseHugoCSV(csv.slice(0, -3)), /Unclosed/);
});

test('publication lists separate known cold module progress without accepting corrupted CSV', () => {
  const csv = `path,slug,title,date,expiryDate,publishDate,draft,permalink,kind,section\npost.md,due,Due,${page.publishDate},${page.expiryDate},${page.publishDate},false,${url},page,post\n`;
  const progress = 'hugo: collected modules in 58666 ms';
  for (const progress of ['hugo: collected modules in 58666 ms', 'hugo: downloading modules \u2026\n', 'hugo: downloading modules \u2026\r\n', '']) {
    for (const mode of ['all', 'published']) {
      const pages = listPublicationPages(mode, {
        run: (binary, args) => {
          assert.equal(binary, 'hugo');
          assert.deepEqual(args, ['list', mode, '--environment', 'production']);
          return `${progress}${csv}`;
        },
      });
      assert.equal(pages[0].path, 'post.md');
    }
  }
  assert.throws(() => parseHugoCSV(`${progress}${csv}`), /Missing Hugo CSV column: path/);
  assert.throws(() => listPublicationPages('all', { run: () => `Unknown warning\n${csv}` }), /Unexpected Hugo publication CSV header/);
  assert.throws(() => listPublicationPages('all', { run: () => `${progress}path,broken\n` }), /Unexpected Hugo publication CSV header/);
  assert.throws(() => listPublicationPages('all', { run: () => { throw new Error('Module download failed'); } }), /Module download failed/);
});

test('failed Hugo commands retain both diagnostic streams and original failure', (context) => {
  const messages = [];
  context.mock.method(console, 'error', (message) => messages.push(message));
  const failure = Object.assign(new Error('Command failed'), {
    stdout: Buffer.from('Error: binary with name "go" not found in PATH\n'),
    stderr: Buffer.from('Additional diagnostic\n'),
  });
  assert.throws(() => listPublicationPages('all', { run: () => { throw failure; } }), (error) => error === failure);
  assert.deepEqual(messages, [
    'Hugo stdout:\nError: binary with name "go" not found in PATH',
    'Hugo stderr:\nAdditional diagnostic',
  ]);
});

test('real Hugo module failure without Go retains its stdout diagnostic', (context) => {
  const dir = mkdtempSync(path.join(os.tmpdir(), 'blog-missing-go-'));
  const messages = [];
  context.mock.method(console, 'error', (message) => messages.push(message));
  // Resolve Hugo before removing PATH; otherwise Linux fails to spawn Hugo itself.
  const hugo = process.env.HUGO_BIN || process.env.PATH.split(path.delimiter)
    .map((directory) => path.join(directory, process.platform === 'win32' ? 'hugo.exe' : 'hugo'))
    .find(existsSync);
  assert(hugo && path.isAbsolute(hugo), 'An absolute Hugo binary path is required for the missing-Go test');
  try {
    writeFileSync(path.join(dir, 'hugo.toml'), '[[module.imports]]\npath = "github.com/CaiJimmy/hugo-theme-stack/v3"\n');
    writeFileSync(path.join(dir, 'go.mod'), 'module missinggotest\n\ngo 1.19\n');
    assert.throws(() => listPublicationPages('all', {
      hugo,
      run: (binary, args, options) => execFileSync(binary, args, {
        ...options, cwd: dir, env: { ...process.env, PATH: dir, HUGO_CACHEDIR: path.join(dir, 'cache') },
      }),
    }), (error) => error.status === 1);
    assert(messages.some((message) => message.includes('binary with name "go" not found in PATH')));
  } finally { rmSync(dir, { recursive: true, force: true }); }
});

test('due metadata uses effective publishDate, draft, expiry and real time', () => {
  const future = { ...page, path: 'future.md', permalink: url.replace('due', 'future'), publishDate: '2026-11-08T20:00:00+11:00' };
  const draft = { ...page, path: 'draft.md', permalink: url.replace('due', 'draft'), draft: 'true' };
  const expired = { ...page, path: 'expired.md', permalink: url.replace('due', 'expired'), expiryDate: '2026-11-08T01:00:00Z' };
  const headless = { ...page, path: 'headless.md', permalink: '' };
  const result = createManifest([page, future, draft, expired, headless], [page], now);
  assert.deepEqual(result.due, [{ path: page.path, url }]);
  assert.equal(result.excluded.length, 3);
  assert.throws(() => createManifest([page], [], now), /Due-today page omitted/);
  assert.throws(() => createManifest([future], [future], now), /Excluded content is published/);
});

test('output paths respect explicit HTML URLs and reject unsafe or external paths', () => {
  assert.equal(outputFile(url, 'public'), path.resolve('public', 'p', 'due', 'index.html'));
  assert.equal(outputFile('https://blog.example.com/custom.html', 'public'), path.resolve('public', 'custom.html'));
  for (const invalid of [
    'https://example.com/p/due/', 'https://blog.example.com/%2e%2e%2fescape.html',
    'https://blog.example.com/%5cescape.html', `${url}?query=1`,
  ]) assert.throws(() => outputFile(invalid, 'public'));
});

test('predeploy checks missing due output, leaked future output, and no-due homepage', () => {
  const dir = mkdtempSync(path.join(os.tmpdir(), 'blog-publication-output-'));
  try {
    const manifest = createManifest([page], [page], now);
    assert.throws(() => validateBuild(manifest, dir), /ENOENT/);
    mkdirSync(path.dirname(outputFile(url, dir)), { recursive: true });
    writeFileSync(outputFile(url, dir), '<html>Due</html>');
    assert.equal(validateBuild(manifest, dir).length, 1);
    assert.throws(() => validateBuild({ due: [], excluded: [{ path: 'future', url }] }, dir), /Excluded page rendered/);
    assert.throws(() => validateBuild({ due: [], excluded: [] }, dir), /ENOENT/);
    writeFileSync(path.join(dir, 'index.html'), '<html>Home</html>');
    assert.equal(validateBuild({ due: [], excluded: [] }, dir)[0].url, 'https://blog.example.com/');
  } finally { rmSync(dir, { recursive: true, force: true }); }
});

test('postdeploy retries propagation failures but rejects stale/SPA content and redirects', async () => {
  const bytes = Buffer.from('<html>New page</html>');
  const checks = [{ url, sha256: createHash('sha256').update(bytes).digest('hex') }];
  let clock = 0, calls = 0;
  const wait = async (ms) => { clock += ms; };
  await verifyPublic(checks, {
    now: () => clock, wait,
    fetcher: async (target, options) => {
      assert.equal(options.redirect, 'error');
      assert.equal(target.searchParams.get('publication_check'), checks[0].sha256);
      calls++;
      return new Response(calls === 1 ? 'Not yet' : bytes, { status: calls === 1 ? 404 : 200 });
    },
  });
  assert.equal(calls, 2);
  for (const response of [() => new Response('SPA fallback'), () => new Response('', { status: 302 }), () => { throw new Error('Network down'); }]) {
    clock = 0;
    await assert.rejects(verifyPublic(checks, {
      now: () => clock, wait, budgetMs: 20, retryMs: 10, fetcher: async () => response(),
    }), /Public verification failed/);
  }
  await assert.rejects(verifyPublic([]), /No public verification targets/);
});

test('real Hugo resolves YAML/TOML dates, publishDate overrides, slugs, URLs and headless bundles', () => {
  const dir = mkdtempSync(path.join(os.tmpdir(), 'blog-publication-hugo-'));
  const hugo = process.env.HUGO_BIN || 'hugo';
  try {
    mkdirSync(path.join(dir, 'content', 'post', 'hidden'), { recursive: true });
    mkdirSync(path.join(dir, 'layouts', '_default'), { recursive: true });
    writeFileSync(path.join(dir, 'hugo.toml'), 'baseURL = "https://blog.example.com"\n[permalinks]\npost = "/p/:slug/"\n');
    writeFileSync(path.join(dir, 'layouts', '_default', 'single.html'), '<html>{{ .Title }}</html>');
    writeFileSync(path.join(dir, 'layouts', 'index.html'), '<html>Home</html>');
    const current = new Date();
    const past = new Date(current.getTime() - 1000).toISOString();
    const future = new Date(current.getTime() + 86400000).toISOString();
    const write = (name, content) => writeFileSync(path.join(dir, 'content', 'post', name), content);
    write('different-filename.md', `---\ntitle: Due\ndate: ${future}\npublishDate: ${past}\nslug: effective-slug\n---\nDue`);
    write('explicit-url.md', `+++\ntitle = "Explicit"\ndate = ${past}\nurl = "/custom.html"\n+++\nContent`);
    write('future.md', `---\ntitle: Future\ndate: ${past}\npublishDate: ${future}\n---\nFuture`);
    write('draft.md', `---\ntitle: Draft\ndate: ${past}\ndraft: true\n---\nDraft`);
    write('expired.md', `---\ntitle: Expired\ndate: ${past}\nexpiryDate: ${past}\n---\nExpired`);
    write(path.join('hidden', 'index.md'), `---\ntitle: Hidden\ndate: ${past}\nheadless: true\n---\nHidden`);
    const run = (...args) => execFileSync(hugo, args, { cwd: dir, encoding: 'utf8' });
    const all = listPublicationPages('all', { hugo, run: (binary, args) => run(...args) });
    const published = listPublicationPages('published', { hugo, run: (binary, args) => run(...args) });
    const manifest = createManifest(all, published, new Date());
    assert.deepEqual(manifest.due.map((item) => item.url).sort(), [
      'https://blog.example.com/custom.html', 'https://blog.example.com/p/effective-slug/',
    ]);
    assert.equal(manifest.excluded.length, 3);
    run('--minify');
    const checks = validateBuild(manifest, path.join(dir, 'public'));
    assert.equal(checks.length, 2);
    assert.match(readFileSync(outputFile(checks[1].url, path.join(dir, 'public')), 'utf8'), /<html>/);
  } finally { rmSync(dir, { recursive: true, force: true }); }
});

blog\package.json

1
2
3
4
5
6
7
{
  "name": "brewed-in-the-cloud-blog",
  "private": true,
  "scripts": {
    "test": "node --test scripts/publication-checks.test.mjs"
  }
}

The intended invariant is unchanged: hugo --minify uses the real clock and excludes future content. Due pages must exist in the built output; future, draft or expired content must not leak. The upload should consume the checked prebuilt output, and public verification must reject stale or fallback content rather than accepting HTTP 200 alone.

The included workflow explicitly installs Hugo 0.152.2 extended, Go 1.24.x and Node.js 22. Known cold-module progress is separated from Hugo CSV; unexpected metadata fails closed.

Eleventy workflow and publication guards

Save these files in the existing Eleventy repository. Preserve unrelated site-data values by merging the example data export; its personal links are placeholders. Preserve your locked dependencies and existing sync helper if retaining that script. All publication utility dependencies are included. Public HTML comparison normalizes CRLF only; this is not Hugo draft/expiry parsing or a raw-byte hash.

ai-strategy-blog\.github\workflows\azure-static-web-apps.yml

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
name: Azure Static Web Apps CI/CD
run-name: ${{ inputs.cycle_id != '' && format('Azure daily publish / {0}', inputs.cycle_id) || format('{0} / {1}', github.workflow, github.event_name) }}

on:
  workflow_dispatch:
    inputs:
      cycle_id:
        description: Azure controller cycle UUID
        type: string
      publish_date:
        description: Actual Australia/Sydney date, yyyy-MM-dd
        type: string
  push:
    branches:
      - main
    paths:
      - src/**
      - .eleventy.js
      - package.json
      - package-lock.json
      - scripts/**
      - .github/workflows/azure-static-web-apps.yml
  pull_request:
    types: [synchronize, reopened]
    branches:
      - main
    paths:
      - src/**
      - .eleventy.js
      - package.json
      - package-lock.json
      - scripts/**
      - .github/workflows/azure-static-web-apps.yml
  schedule:
    # 6:00 AM AEST (UTC+10) daily — rebuilds the site so future-dated posts go live on their publish date
    - cron: '0 20 * * *'

concurrency:
  group: swa-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: read
  pull-requests: write

jobs:
  validate_dispatch:
    if: github.event_name != 'schedule' || vars.AZURE_DAILY_PUBLISHING_ENABLED != 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - name: Validate runner dispatch
        run: node scripts/publication-checks.cjs dispatch
        env:
          CYCLE_ID: ${{ inputs.cycle_id }}
          PUBLISH_DATE: ${{ inputs.publish_date }}

  build_and_deploy_job:
    needs: validate_dispatch
    runs-on: ubuntu-latest
    timeout-minutes: 30
    name: Build and Deploy Job
    env:
      PUBLISH_DATE: ${{ inputs.publish_date }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Test publication checks
        run: npm test

      - name: Remove stale build output
        run: rm -rf -- _site

      - name: Build Eleventy site
        run: npm run build

      - name: Verify publication output
        run: node scripts/publication-checks.cjs build

      - name: Build and Deploy
        id: builddeploy
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          action: upload
          app_location: _site
          api_location: ""
          output_location: ""
          skip_app_build: true

      - name: Verify today's production pages
        if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
        run: node scripts/publication-checks.cjs live

ai-strategy-blog\scripts\publication-checks.cjs

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { getSydneyIsoDate } = require("../src/_utils/publish-window");
const { siteUrl } = require("../src/_data/site");

function publicationDate(expected, now = new Date()) {
  const today = getSydneyIsoDate(now);
  if (expected) assert.equal(expected, today, "publish_date must be the actual Sydney date");
  return today;
}

function validateDispatch(env = process.env, now = new Date()) {
  if (env.GITHUB_EVENT_NAME !== "workflow_dispatch") return;
  if (env.CYCLE_ID) {
    assert.equal(env.GITHUB_REF, "refs/heads/main", "Azure dispatch must target main");
    assert.match(env.CYCLE_ID || "", /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "cycle_id must be a UUID");
    assert.match(env.PUBLISH_DATE || "", /^\d{4}-\d{2}-\d{2}$/, "publish_date is required");
    publicationDate(env.PUBLISH_DATE, now);
    return;
  }
  if (env.PUBLISH_DATE) publicationDate(env.PUBLISH_DATE, now);
}

function readPosts(root = process.cwd()) {
  return fs.readdirSync(path.join(root, "src", "posts"))
    .filter((name) => name.endsWith(".md"))
    .map((name) => {
      const text = fs.readFileSync(path.join(root, "src", "posts", name), "utf8");
      const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
      assert.ok(frontmatter, `${name}: missing frontmatter`);
      const date = frontmatter[1].match(/^date:\s*(\d{4}-\d{2}-\d{2})\s*$/m)?.[1];
      assert.ok(date && new Date(date).toISOString().slice(0, 10) === date, `${name}: invalid date-only publication date`);
      // These posts use Eleventy's default /posts/<filename>/ route.
      assert.ok(!/^permalink:/m.test(frontmatter[1]), `${name}: custom permalink needs publication-check support`);
      return { date, url: `/posts/${name.slice(0, -3)}/` };
    });
}

function checkBuild(posts, today, root = process.cwd()) {
  const output = path.join(root, "_site");
  const index = fs.readFileSync(path.join(output, "posts", "index.html"), "utf8");
  const listings = [index, fs.readFileSync(path.join(output, "index.html"), "utf8")];
  const pages = path.join(output, "page");
  if (fs.existsSync(pages)) {
    for (const entry of fs.readdirSync(pages)) {
      listings.push(fs.readFileSync(path.join(pages, entry, "index.html"), "utf8"));
    }
  }
  for (const post of posts) {
    const exists = fs.existsSync(path.join(output, ...post.url.split("/").filter(Boolean), "index.html"));
    const listed = index.includes(`href="${post.url}"`);
    if (post.date > today) {
      assert.ok(!exists && !listings.some((html) => html.includes(`href="${post.url}"`)),
        `Future post leaked: ${post.url}`);
    } else if (post.date === today) {
      assert.ok(exists && listed, `Due-today post missing: ${post.url}`);
    }
  }
}

async function checkLive(posts, today, {
  fetchPage = fetch,
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
  attempts = 12,
  delay = 10000,
  readPage = (url) => fs.readFileSync(path.join(process.cwd(), "_site",
    ...url.split("/").filter(Boolean), "index.html"), "utf8"),
} = {}) {
  const due = posts.filter((post) => post.date === today);
  if (!due.length) {
    console.log(`No posts due on ${today}; verifying deployed homepage.`);
  }
  const pending = new Set(["/", ...due.map((post) => post.url)]);
  const normalizeNewlines = (html) => html.replace(/\r\n/g, "\n");
  const expectedPages = new Map([...pending].map((url) => [url, normalizeNewlines(readPage(url))]));
  const failures = new Map();
  for (let attempt = 0; attempt < attempts && pending.size; attempt++) {
    for (const url of pending) {
      try {
        const response = await fetchPage(`${siteUrl}${url}?publication_check=${today}-${attempt}`, {
          signal: AbortSignal.timeout(10000),
          headers: { "Cache-Control": "no-cache" },
        });
        const body = await response.text();
        assert.ok(response.ok && normalizeNewlines(body) === expectedPages.get(url),
          `${url}: HTTP ${response.status}, content does not match deployed build`);
        pending.delete(url);
      } catch (error) {
        failures.set(url, error.message);
        console.error(`Publication check ${attempt + 1}/${attempts}: ${error.message}`);
      }
    }
    if (pending.size && attempt + 1 < attempts) await sleep(delay);
  }
  assert.equal(pending.size, 0, `Production verification failed: ${[...pending].map((url) => `${url}: ${failures.get(url)}`).join("; ")}`);
}

async function main() {
  const mode = process.argv[2];
  if (mode === "dispatch") return validateDispatch();
  assert.ok(["build", "live"].includes(mode), "Expected dispatch, build or live");
  const today = publicationDate(process.env.PUBLISH_DATE);
  const posts = readPosts();
  checkBuild(posts, today);
  if (mode === "live") await checkLive(posts, today);
  console.log(`Publication checks passed for ${today}.`);
}

module.exports = { publicationDate, validateDispatch, readPosts, checkBuild, checkLive };
if (require.main === module) main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

ai-strategy-blog\scripts\publication-checks.test.cjs

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { publicationDate, validateDispatch, readPosts, checkBuild, checkLive } = require("./publication-checks.cjs");
const { isPublishedOnSydneyDate } = require("../src/_utils/publish-window");
const computed = require("../src/posts/posts.11tydata").eleventyComputed;

const now = new Date("2026-11-07T13:00:00Z");
const azure = {
  GITHUB_EVENT_NAME: "workflow_dispatch",
  GITHUB_REF: "refs/heads/main",
  CYCLE_ID: "8c4dab1d-b228-4d28-9f5f-13c383d3d3e0",
  PUBLISH_DATE: "2026-11-08",
};

test("publication uses Sydney midnight and respects daylight saving", () => {
  assert.equal(publicationDate("", now), "2026-11-08");
  assert.equal(publicationDate("", new Date("2026-11-07T12:59:59Z")), "2026-11-07");
  assert.equal(publicationDate("", new Date("2026-06-01T14:00:00Z")), "2026-06-02");
  assert.equal(isPublishedOnSydneyDate(new Date("2026-11-08"), now), true);
  assert.equal(isPublishedOnSydneyDate(new Date("2026-11-09"), now), false);
  assert.throws(() => publicationDate("2026-11-09", now));
  assert.equal(computed.permalink({ date: new Date("2999-01-01") }), false);
});

test("Azure dispatch identified by cycle_id requires main, UUID and today's date", () => {
  validateDispatch(azure, now);
  for (const patch of [
    { GITHUB_REF: "refs/heads/feature" },
    { CYCLE_ID: "bad" },
    { PUBLISH_DATE: "" },
    { PUBLISH_DATE: "2026-11-09" },
    { PUBLISH_DATE: "2026-02-30" },
  ]) assert.throws(() => validateDispatch({ ...azure, ...patch }, now));
});

test("manual dispatch without a cycle id needs no Azure metadata", () => {
  validateDispatch({ GITHUB_EVENT_NAME: "workflow_dispatch" }, now);
  validateDispatch({ GITHUB_EVENT_NAME: "workflow_dispatch", PUBLISH_DATE: "2026-11-08" }, now);
  assert.throws(() => validateDispatch({
    GITHUB_EVENT_NAME: "workflow_dispatch", PUBLISH_DATE: "2026-11-09",
  }, now));
  for (const event of ["push", "pull_request", "schedule"]) {
    validateDispatch({ GITHUB_EVENT_NAME: event }, now);
  }
});

test("built due pages must exist and future pages must not leak", (t) => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "strategy-publication-"));
  t.after(() => fs.rmSync(root, { recursive: true }));
  const write = (file, body) => {
    fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
    fs.writeFileSync(path.join(root, file), body);
  };
  write("src/posts/today.md", "---\ndate: 2026-11-08\n---\nToday");
  write("src/posts/future.md", "---\ndate: 2026-11-09\n---\nFuture");
  write("_site/posts/index.html", '<a href="/posts/today/">Today</a>');
  write("_site/index.html", '<a href="/posts/today/">Today</a>');
  const posts = readPosts(root);
  assert.throws(() => checkBuild(posts, "2026-11-08", root));
  write("_site/posts/today/index.html", "Today");
  checkBuild(posts, "2026-11-08", root);
  write("_site/posts/future/index.html", "Future");
  assert.throws(() => checkBuild(posts, "2026-11-08", root));
  fs.rmSync(path.join(root, "_site/posts/future/index.html"));
  write("_site/posts/index.html", '<a href="/posts/future/">Future</a>');
  assert.throws(() => checkBuild(posts, "2026-11-08", root));
  write("_site/posts/index.html", '<a href="/posts/today/">Today</a>');
  write("_site/page/2/index.html", '<a href="/posts/future/">Future</a>');
  assert.throws(() => checkBuild(posts, "2026-11-08", root));
  write("src/posts/future.md", "---\ndate: 2026-02-30\n---\nBad date");
  assert.throws(() => readPosts(root));
});

test("live verification retries propagation but fails boundedly", async () => {
  let calls = 0;
  const posts = [{ date: "2026-11-08", url: "/posts/today/" }];
  const html = '<article class="post">2026-11-08</article>';
  await checkLive(posts, "2026-11-08", {
    attempts: 2, sleep: async () => {},
    readPage: () => html,
    fetchPage: async () => {
      calls++;
      return { ok: calls > 1, status: calls > 1 ? 200 : 404,
        text: async () => '<article class="post">2026-11-08</article>' };
    },
  });
  assert.equal(calls, 3);
  calls = 0;
  await assert.rejects(checkLive(posts, "2026-11-08", {
    attempts: 2, sleep: async () => {},
    readPage: () => html,
    fetchPage: async () => { calls++; throw new Error("network unavailable"); },
  }));
  assert.equal(calls, 4);
  await assert.rejects(checkLive(posts, "2026-11-08", {
    attempts: 1,
    readPage: () => html,
    fetchPage: async () => ({ ok: true, status: 200, text: async () => "Fallback page" }),
  }));
});

test("no posts due today verifies homepage bytes and rejects soft404", async () => {
  let calls = 0;
  await checkLive([], "2026-11-08", {
    readPage: () => "<html>\r\nExpected homepage</html>",
    fetchPage: async (url) => {
      calls++;
      assert.ok(url.startsWith("https://strategy.example.com/?"));
      return { ok: true, status: 200, text: async () => "<html>\nExpected homepage</html>" };
    },
  });
  assert.equal(calls, 1);
  await assert.rejects(checkLive([], "2026-11-08", {
    attempts: 1,
    readPage: () => "<html>Expected homepage</html>",
    fetchPage: async () => ({ ok: true, status: 200, text: async () => "<html>Not found</html>" }),
  }));
});

test("real Eleventy build emits today's post and suppresses tomorrow's post", (t) => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "strategy-eleventy-"));
  t.after(() => fs.rmSync(root, { recursive: true }));
  const repo = path.resolve(__dirname, "..");
  fs.cpSync(path.join(repo, "src"), path.join(root, "src"), { recursive: true });
  fs.copyFileSync(path.join(repo, ".eleventy.js"), path.join(root, ".eleventy.js"));
  const today = publicationDate("");
  const tomorrow = new Date(`${today}T00:00:00Z`);
  tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
  for (const [name, date] of [
    ["publication-test-today", today],
    ["publication-test-tomorrow", tomorrow.toISOString().slice(0, 10)],
  ]) {
    fs.writeFileSync(path.join(root, "src", "posts", `${name}.md`),
      `---\ntitle: ${name}\ndate: ${date}\nlayout: layouts/post.njk\n---\nPublication test.\n`);
  }
  execFileSync(process.execPath, [path.join(repo, "node_modules", "@11ty", "eleventy", "cmd.js")], {
    cwd: root,
    timeout: 60000,
    stdio: "pipe",
  });
  checkBuild(readPosts(root), today, root);
  assert.ok(fs.existsSync(path.join(root, "_site", "posts", "publication-test-today", "index.html")));
  assert.ok(!fs.existsSync(path.join(root, "_site", "posts", "publication-test-tomorrow", "index.html")));
});

ai-strategy-blog\src\_utils\publish-window.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const SYDNEY_TIME_ZONE = "Australia/Sydney";

function getSydneyIsoDate(now = new Date()) {
  const formatter = new Intl.DateTimeFormat("en-CA", {
    timeZone: SYDNEY_TIME_ZONE,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  });

  return formatter.format(now);
}

function getDateOnlyIso(dateValue) {
  if (!(dateValue instanceof Date) || Number.isNaN(dateValue.getTime())) {
    return null;
  }

  return dateValue.toISOString().slice(0, 10);
}

function isPublishedOnSydneyDate(dateValue, now = new Date()) {
  const publishDate = getDateOnlyIso(dateValue);
  if (!publishDate) {
    return true;
  }

  return publishDate <= getSydneyIsoDate(now);
}

module.exports = {
  getSydneyIsoDate,
  getDateOnlyIso,
  isPublishedOnSydneyDate,
};

ai-strategy-blog\src\posts\posts.11tydata.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const { isPublishedOnSydneyDate } = require("../_utils/publish-window");

// Suppress page generation for future-dated posts.
// The post will still appear in the collection once its date arrives.
module.exports = {
  eleventyComputed: {
    permalink: (data) => {
      if (!isPublishedOnSydneyDate(data.date)) {
        return false;
      }
      return data.permalink;
    },
  },
};

ai-strategy-blog\src\_data\site.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
module.exports = {
  siteUrl: "https://strategy.example.com",
  clarityProjectId: process.env.CLARITY_PROJECT_ID || "<YOUR-CLARITY-PROJECT-ID>",
  links: {
    brewedInTheCloudBlog: "https://blog.example.com",
    github: "https://github.com/<YOUR-GITHUB-ACCOUNT>/",
    linkedin: "https://www.linkedin.com/in/<YOUR-LINKEDIN-PROFILE>/",
    cubesys: "https://<YOUR-ORGANIZATION-DOMAIN>/",
    mvp: "https://mvp.microsoft.com/en-US/mvp/profile/<YOUR-MVP-PROFILE-ID>"
  }
};

ai-strategy-blog\.eleventy.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const { isPublishedOnSydneyDate } = require("./src/_utils/publish-window");

module.exports = function (eleventyConfig) {
  eleventyConfig.addPassthroughCopy("src/assets");

  eleventyConfig.addCollection("posts", function (collectionApi) {
    return collectionApi.getFilteredByGlob("src/posts/*.md")
      .filter((item) => isPublishedOnSydneyDate(item.date))
      .reverse();
  });

  eleventyConfig.addFilter("dateIso", function (value) {
    if (!(value instanceof Date)) {
      return value;
    }

    return value.toISOString().slice(0, 10);
  });

  return {
    dir: {
      input: "src",
      includes: "_includes",
      output: "_site"
    }
  };
};

ai-strategy-blog\package.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "name": "eleventy-azure-static-web-app",
  "version": "1.0.0",
  "private": true,
  "description": "Eleventy markdown blog with Azure Static Web Apps CI/CD",
  "engines": {
    "node": "20.x"
  },
  "scripts": {
    "test": "node --test scripts/publication-checks.test.cjs",
    "build": "eleventy",
    "serve": "eleventy --serve",
    "sync:main": "node scripts/sync-primary-checkout.mjs"
  },
  "devDependencies": {
    "@11ty/eleventy": "^2.0.1"
  }
}

Keep the site’s date-only Sydney semantics distinct from Hugo timestamps. The Eleventy guard requires a date: YYYY-MM-DD front-matter value on every post and fails explicitly on a custom permalink, because it assumes Eleventy’s default /posts/<filename>/ route. It does not implement Hugo-style draft/expiry handling or raw-byte hashing.

Deployment order and observable results

These complete operator scripts are explicit Azure/GitHub writes when executed by a reader. MigrateHosted publishes content, performs read-only readiness, verifies monitoring and then activates all three schedules; it is NOT a disabled dry run. ContentOnly publishes only the bundles. Supervised dispatch is a separate manual acceptance path, not production-controller proof.

Automations\scripts\Publish-RunnerRunbooks.ps1

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# Explicit operator command; never invoked automatically by builds.
[CmdletBinding()]
param(
    [string]$SubscriptionId = '<YOUR-SUBSCRIPTION-ID>',
    [string]$ResourceGroup = 'rg-github-runner',
    [string]$AutomationName = 'aa-gh-publishing',
    [switch]$ContentOnly,
    [switch]$MigrateHosted,
    [datetimeoffset]$DailyStartTime,
    [string]$WorkspaceResourceId = '/subscriptions/<YOUR-SUBSCRIPTION-ID>/resourceGroups/rg-management/providers/Microsoft.OperationalInsights/workspaces/<YOUR-WORKSPACE-NAME>',
    [string]$WorkspaceLocation = 'australiaeast'
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$base = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Automation/automationAccounts/$AutomationName"
$api = '?api-version=2024-10-23'
function Invoke-DeploymentRest {
    param([string]$Method, [string]$Path, $Body = $null, [string]$ContentFile = '')
    $arguments = @('rest', '--method', $Method, '--url', "$base$Path$api", '--subscription', $SubscriptionId, '--output', 'json')
    $temporary = $null
    try {
        if ($ContentFile) {
            $arguments += @('--headers', 'Content-Type=text/powershell', '--body', "@$ContentFile")
        } elseif ($null -ne $Body) {
            $temporary = [IO.Path]::GetTempFileName()
            [IO.File]::WriteAllText($temporary, ($Body | ConvertTo-Json -Depth 30 -Compress))
            $arguments += @('--headers', 'Content-Type=application/json', '--body', "@$temporary")
        }
        $output = & az @arguments
        if ($LASTEXITCODE -ne 0) { throw "Azure publication $Method $Path failed." }
        if ($output) { return ($output -join "`n" | ConvertFrom-Json) }
    } finally { if ($temporary) { Remove-Item -LiteralPath $temporary } }
}
function Get-ValidatedJobScheduleId {
    param($Link)
    $guid = [guid]::Empty
    $value = [string]$Link.properties.jobScheduleId
    if (-not [guid]::TryParse($value, [ref]$guid) -or
        -not [string]::Equals([string]$Link.id, "$($base.Substring('https://management.azure.com'.Length))/jobSchedules/$value", [StringComparison]::OrdinalIgnoreCase)) {
        throw 'Job schedule ID does not match the current Automation account; refusing deletion.'
    }
    return $value
}
if ($ContentOnly -and $MigrateHosted) { throw 'Choose ContentOnly or MigrateHosted, not both.' }
$zone = [TimeZoneInfo]::FindSystemTimeZoneById($(if ($IsWindows) { 'AUS Eastern Standard Time' } else { 'Australia/Sydney' }))
$localNow = [TimeZoneInfo]::ConvertTime([datetimeoffset]::UtcNow, $zone)
if ($MigrateHosted -and $localNow.Hour -lt 7) { throw 'Hosted cutover forbidden from 00:00 to 07:00 Sydney.' }
if (-not $ContentOnly) {
    if ($DailyStartTime -lt [datetimeoffset]::UtcNow.AddMinutes(10)) { throw 'DailyStartTime must be at least ten minutes in the future.' }
    $localStart = [TimeZoneInfo]::ConvertTime($DailyStartTime, $zone)
    if ($localStart.Hour -ne 0 -or $localStart.Minute -ne 30 -or $localStart.Second -ne 0) { throw 'DailyStartTime must be exactly 00:30 Sydney.' }
}
$active = Invoke-DeploymentRest GET '/jobs'
if ($active.nextLink) { throw 'Job inventory truncated; inspect all active jobs before publication.' }
if (@($active.value | Where-Object {
    $_.properties.runbook.name -in @('Start-DailyPublishing', 'Stop-RunnerWatchdog', 'Test-RunnerReadiness') -and
    $_.properties.status -notin @('Completed', 'Failed', 'Stopped', 'Suspended')
}).Count -gt 0) { throw 'Controller/probe job active; wait before publication.' }
$configVariable = Invoke-DeploymentRest GET '/variables/RunnerConfig'
$raw = $configVariable.properties.value
# Reuse production validation, without any credentials or network access.
. (Join-Path $root 'runbooks\Runner.Common.ps1')
$configuration = ConvertFrom-RunnerConfig $raw
function Get-AutomationVariable { return $configuration }
Initialize-Runner
Assert-PublishingConfiguration
$account = Invoke-DeploymentRest GET ''
$common = Get-Content -LiteralPath (Join-Path $root 'runbooks\Runner.Common.ps1') -Raw
foreach ($name in @('Test-RunnerReadiness', 'Start-DailyPublishing', 'Stop-RunnerWatchdog')) {
    $null = Invoke-DeploymentRest PUT "/runbooks/$name" @{
        location = $account.location
        properties = @{
            runbookType = 'PowerShell'; runtimeEnvironment = 'PublishingPowerShell74'
            draft = @{}; logProgress = $false; logVerbose = $false
            description = 'Bounded Azure-triggered GitHub-hosted publishing.'
        }
    }
    $content = $common + "`n" + (Get-Content -LiteralPath (Join-Path $root "runbooks\$name.ps1") -Raw)
    $file = [IO.Path]::GetTempFileName()
    try {
        [IO.File]::WriteAllText($file, $content)
        $null = Invoke-DeploymentRest PUT "/runbooks/$name/draft/content" -ContentFile $file
        $null = Invoke-DeploymentRest POST "/runbooks/$name/publish"
        $deadline = [datetimeoffset]::UtcNow.AddMinutes(2)
        do {
            $saved = Invoke-DeploymentRest GET "/runbooks/$name"
            if ($saved.properties.state -eq 'Published') { break }
            if ([datetimeoffset]::UtcNow -ge $deadline) { throw "Runbook $name publication timed out." }
            Start-Sleep -Seconds 2
        } while ($true)
        $published = & az rest --method get --url "$base/runbooks/$name/content$api" --subscription $SubscriptionId --output tsv
        if ($LASTEXITCODE -ne 0 -or ($published -join "`n").Replace("`r`n", "`n").TrimEnd() -cne $content.Replace("`r`n", "`n").TrimEnd()) {
            throw "Runbook $name published content differs from source."
        }
    } finally { Remove-Item -LiteralPath $file }
}
if ($ContentOnly) { Write-Output 'Three hosted runbook bundles verified; schedules/links unchanged.'; return }

if ($MigrateHosted) {
    $jobId = [guid]::NewGuid().ToString()
    $null = Invoke-DeploymentRest PUT "/jobs/$jobId" @{ properties = @{ runbook = @{ name = 'Test-RunnerReadiness' } } }
    $deadline = [datetimeoffset]::UtcNow.AddMinutes(10)
    do {
        $job = Invoke-DeploymentRest GET "/jobs/$jobId"
        if ($job.properties.status -eq 'Completed') { break }
        if ($job.properties.status -in @('Failed', 'Stopped', 'Suspended') -or [datetimeoffset]::UtcNow -ge $deadline) {
            throw "Read-only migration preflight $jobId did not complete; old schedules retained."
        }
        Start-Sleep -Seconds 5
    } while ($true)
}
$plans = @(
    @{ name = 'SydneyDaily0030'; start = $DailyStartTime; runbook = 'Start-DailyPublishing' }
    @{ name = 'PublishingRecovery0130'; start = [datetimeoffset][TimeZoneInfo]::ConvertTimeToUtc(
        [datetime]::SpecifyKind($localStart.Date.AddHours(1).AddMinutes(30), [DateTimeKind]::Unspecified), $zone); runbook = 'Stop-RunnerWatchdog' }
    @{ name = 'PublishingDeadline0630'; start = [datetimeoffset][TimeZoneInfo]::ConvertTimeToUtc(
        [datetime]::SpecifyKind($localStart.Date.AddHours(6).AddMinutes(30), [DateTimeKind]::Unspecified), $zone); runbook = 'Stop-RunnerWatchdog' }
)
foreach ($plan in $plans) {
    $existing = @( (Invoke-DeploymentRest GET '/schedules').value | Where-Object name -eq $plan.name )
    if ($existing.Count -eq 0) {
        $null = Invoke-DeploymentRest PUT "/schedules/$($plan.name)" @{
            properties = @{ startTime = $plan.start.ToString('o'); timeZone = 'Australia/Sydney'; frequency = 'Day'; interval = 1 }
        }
        $null = Invoke-DeploymentRest PATCH "/schedules/$($plan.name)" @{ properties = @{ isEnabled = $false } }
    } elseif (-not $MigrateHosted -and $existing[0].properties.isEnabled) { throw 'Enabled installation requires explicit MigrateHosted.' }
    if ($existing.Count -gt 0) {
        $props = $existing[0].properties
        $savedTime = [TimeZoneInfo]::ConvertTime([datetimeoffset]::Parse($props.startTime), $zone)
        $desiredTime = [TimeZoneInfo]::ConvertTime($plan.start, $zone)
        if ($props.frequency -ne 'Day' -or $props.interval -ne 1 -or $props.timeZone -ne 'Australia/Sydney' -or
            $savedTime.TimeOfDay -ne $desiredTime.TimeOfDay) { throw "Existing $($plan.name) cadence differs; inspect before cutover." }
    }
    $links = (Invoke-DeploymentRest GET '/jobSchedules').value
    $found = @($links | Where-Object { $_.properties.schedule.name -eq $plan.name -and $_.properties.runbook.name -eq $plan.runbook })
    if ($found.Count -gt 1) { throw "Duplicate schedule links for $($plan.name)." }
    if ($found.Count -eq 0) {
        $null = Invoke-DeploymentRest PUT "/jobSchedules/$([guid]::NewGuid())" @{
            properties = @{ schedule = @{ name = $plan.name }; runbook = @{ name = $plan.runbook }; parameters = @{} }
        }
    }
}
if ($MigrateHosted) {
    $alertUrl = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Insights/scheduledQueryRules/publishing-watchdog-missed?api-version=2023-12-01"
    $auth = & az account get-access-token --subscription $SubscriptionId --resource https://management.azure.com/ --output json 2>$null
    if ($LASTEXITCODE -ne 0) { throw 'Cannot authenticate existing absence-alert inspection; migration stopped.' }
    $bearer = ($auth -join "`n" | ConvertFrom-Json).accessToken
    try {
        $response = Invoke-WebRequest -Uri $alertUrl -Method GET -Headers @{ Authorization = "Bearer $bearer" } `
            -SkipHttpErrorCheck -TimeoutSec 30
    } catch { throw 'Cannot inspect existing absence alert; migration stopped.' }
    finally { $bearer = $null; $auth = $null }
    if ($response.StatusCode -eq 404) {
        $activationUtc = $DailyStartTime.ToUniversalTime().ToString('o')
    } elseif ($response.StatusCode -eq 200) {
        $existingAlert = $response.Content | ConvertFrom-Json
        $queries = @($existingAlert.properties.criteria.allOf.query)
        $markers = @([regex]::Matches(($queries -join "`n"), "now\(\)\s*>=\s*datetime\('([^']+)'\)"))
        if ($queries.Count -ne 1 -or $markers.Count -ne 1) { throw 'Existing absence alert activation marker is missing or ambiguous; refusing to reset it.' }
        $activationUtc = $markers[0].Groups[1].Value
        $parsed = [datetimeoffset]::MinValue
        if ($activationUtc -notmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|\+00:00)$' -or
            -not [datetimeoffset]::TryParse($activationUtc, [Globalization.CultureInfo]::InvariantCulture,
                [Globalization.DateTimeStyles]::None, [ref]$parsed)) {
            throw 'Existing absence alert activation marker is malformed; refusing to reset it.'
        }
    } else { throw "Existing absence-alert inspection failed (HTTP $($response.StatusCode)); migration stopped." }
    $paramsFile = [IO.Path]::GetTempFileName()
    try {
        $due = @($configuration.workflows.publishDays | Sort-Object -Unique)
        [IO.File]::WriteAllText($paramsFile, (@{ parameters = @{
            automationName = @{ value = $AutomationName }; workspaceResourceId = @{ value = $WorkspaceResourceId }
            workspaceLocation = @{ value = $WorkspaceLocation }; actionGroupResourceId = @{ value = $configuration.alertReceiver }
            monitoringEnabled = @{ value = $true }; diagnosticsEnabled = @{ value = $true }
            dueWeekdays = @{ value = $due }; heartbeatStartUtc = @{ value = $activationUtc }
        } } | ConvertTo-Json -Depth 10))
        & az deployment group create --subscription $SubscriptionId --resource-group $ResourceGroup --name publishing-monitoring `
            --template-file (Join-Path $root 'infra\runner-alerts.bicep') --parameters "@$paramsFile" --output none
        if ($LASTEXITCODE -ne 0) { throw 'Monitoring deployment failed; old schedules retained.' }
    } finally { Remove-Item -LiteralPath $paramsFile }
    foreach ($suffix in @('metricAlerts/publishing-automation-failures?api-version=2018-03-01', 'scheduledQueryRules/publishing-watchdog-missed?api-version=2023-12-01')) {
        $alert = & az rest --method get --url "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Insights/$suffix" --output json | ConvertFrom-Json
        if ($LASTEXITCODE -ne 0 -or -not $alert.properties.enabled) { throw 'Replacement alert not enabled; old schedules retained.' }
        if ($suffix.StartsWith('metricAlerts')) {
            if ($alert.properties.actions[0].actionGroupId -ne $configuration.alertReceiver -or
                $alert.properties.criteria.allOf[0].metricName -ne 'TotalJob') { throw 'Failure alert scope/action mismatch.' }
        } else {
            if ($alert.properties.actions.actionGroups[0] -ne $configuration.alertReceiver -or
                $alert.properties.scopes[0] -ne $WorkspaceResourceId -or
                $alert.properties.evaluationFrequency -ne 'PT1H') { throw 'Absence alert scope/action/frequency mismatch.' }
            foreach ($day in $due) {
                if (-not $alert.properties.criteria.allOf[0].query.Contains($day)) { throw 'Derived alert cadence mismatch.' }
            }
        }
    }
    foreach ($plan in $plans) {
        $null = Invoke-DeploymentRest PATCH "/schedules/$($plan.name)" @{ properties = @{ isEnabled = $true } }
        $saved = Invoke-DeploymentRest GET "/schedules/$($plan.name)"
        if (-not $saved.properties.isEnabled) { throw 'Replacement schedule activation failed; old schedules retained.' }
    }
    $links = (Invoke-DeploymentRest GET '/jobSchedules').value
    foreach ($plan in $plans) {
        if (@($links | Where-Object { $_.properties.schedule.name -eq $plan.name -and $_.properties.runbook.name -eq $plan.runbook }).Count -ne 1) {
            throw 'Replacement schedule link missing; old schedules retained.'
        }
    }
    # Retire old ticks only after replacement links, enabled schedules and alerts are verified.
    foreach ($name in @('Watchdog0', 'Watchdog1', 'Watchdog2', 'Watchdog3')) {
        if (@((Invoke-DeploymentRest GET '/schedules').value | Where-Object name -eq $name).Count -gt 0) {
            $null = Invoke-DeploymentRest PATCH "/schedules/$name" @{ properties = @{ isEnabled = $false } }
            foreach ($link in @($links | Where-Object { $_.properties.schedule.name -eq $name })) {
                $linkId = Get-ValidatedJobScheduleId $link
                $null = Invoke-DeploymentRest DELETE "/jobSchedules/$linkId"
            }
            $null = Invoke-DeploymentRest DELETE "/schedules/$name"
        }
    }
    $remaining = (Invoke-DeploymentRest GET '/schedules').value
    $remainingLinks = (Invoke-DeploymentRest GET '/jobSchedules').value
    if (@($remaining | Where-Object { $_.name -in @('Watchdog0', 'Watchdog1', 'Watchdog2', 'Watchdog3') }).Count -gt 0 -or
        @($remainingLinks | Where-Object { $_.properties.schedule.name -in @('Watchdog0', 'Watchdog1', 'Watchdog2', 'Watchdog3') }).Count -gt 0) {
        throw 'Legacy schedule/link retirement incomplete.'
    }
}
Write-Output 'Hosted schedules/links published; migration mode activates replacements and retires old ticks. No infrastructure decommission performed.'

Automations\scripts\Invoke-SupervisedPublishing.ps1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Explicit supervised acceptance outside the normal window; production runbooks never use this.
[CmdletBinding()]
param(
    [Parameter(Mandatory)][ValidateSet('blog', 'ai-strategy-blog')][string[]]$Repository,
    [Parameter(Mandatory)][switch]$ConfirmSupervised,
    [string]$SubscriptionId = '<YOUR-SUBSCRIPTION-ID>'
)
$ErrorActionPreference = 'Stop'
if (-not $ConfirmSupervised) { throw 'Explicit supervised acceptance confirmation required.' }
$root = Split-Path $PSScriptRoot -Parent
. (Join-Path $root 'runbooks\Runner.Common.ps1')
$variableUrl = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/rg-github-runner/providers/Microsoft.Automation/automationAccounts/aa-gh-publishing/variables/RunnerConfig?api-version=2024-10-23"
$variable = & az rest --method get --url $variableUrl --output json | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) { throw 'Cannot read nonsecret RunnerConfig.' }
$config = ConvertFrom-RunnerConfig $variable.properties.value
function Get-AutomationVariable { return $config }
Initialize-Runner
Assert-PublishingConfiguration
function Invoke-Azure {
    param($Uri, $Resource)
    $json = & az rest --method get --url $Uri --resource $Resource --output json 2>$null
    if ($LASTEXITCODE -ne 0) { throw 'Operator Key Vault read failed; no credential fallback.' }
    return ($json -join "`n" | ConvertFrom-Json)
}
$script:ReadOnlyProbe = $true
try { $token = Get-GitHubBearer } finally { $script:ReadOnlyProbe = $false }
$cycleId = [guid]::NewGuid().ToString()
$date = (Get-SydneyWindow).date
Write-Output "SUPERVISED acceptance only: cycle_id=$cycleId; publish_date=$date; repositories=$($Repository -join ','). No production state writes."
$headers = @{ Authorization = "Bearer $token"; Accept = 'application/vnd.github+json'; 'X-GitHub-Api-Version' = '2022-11-28' }
try {
    foreach ($repo in @($Repository | Sort-Object -Unique)) {
        $target = @($script:Config.workflows | Where-Object repository -eq $repo)[0]
        try {
            $null = Invoke-RestMethod -Method POST -Uri "https://api.github.com/repos/ExamplePublishing/$repo/actions/workflows/$($target.workflow)/dispatches" `
                -Headers $headers -ContentType 'application/json' -Body (@{ ref = 'main'; inputs = @{ cycle_id = $cycleId; publish_date = $date } } | ConvertTo-Json) -TimeoutSec 20
        } catch { throw "Supervised dispatch to $repo unconfirmed. Do NOT retry; inspect exact cycle_id $cycleId manually." }
        Write-Output "$repo dispatched. Verify exactly one workflow_dispatch/main run named 'Azure daily publish / $cycleId', successful deploy and public content. Supervised success is not an unattended-cycle proof."
    }
} finally { $token = $null; $headers = $null; $script:GitHubToken = $null }

The commands below are yours to run. Nothing in this post deploys resources, changes permissions, enables schedules or publishes on your behalf, so obtain the approvals your organization requires first.

Reader deployment commands: prepare, inspect, deploy, accept, activate

Save the controller files under publishing-controller\Automations, Hugo files under your existing blog root and Eleventy files under your existing strategy root. The paths in each code heading are root-relative. All new code is inline; existing sites, templates and lockfiles are baseline prerequisites. The following commands are documentation, not executed cloud operations.

Intentional substitutions are ExamplePublishing, blog.example.com, strategy.example.com, <YOUR-SUBSCRIPTION-ID>, <YOUR-WORKSPACE-NAME>, <YOUR-ACTION-GROUP-NAME> and the labelled site-data analytics/profile/link placeholders. Replace them consistently before execution. App/installation IDs remain explicit input parameters. Built-in Azure role-definition GUIDs, API versions and test-fixture UUIDs are not personal identifiers and remain unchanged. No live private key, token, email address, tenant ID or installation ID is embedded.

The controller intentionally validates exactly two repository names and main. To use another organization, replace ExamplePublishing everywhere; to change repository/workflow names, update both the Bicep config and the common-library assertions coherently. Keep the compatibility names aa-gh-publishing, rg-github-runner, RunnerConfig and Stop-RunnerWatchdog: the supervised helper hardcodes the account/resource-group path, and the monitoring query names the latter runbook.

In PowerShell 7.4+, from the controller root:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
$SubscriptionId = '<YOUR-SUBSCRIPTION-ID>'
$ResourceGroup = 'rg-github-runner'
$AutomationName = 'aa-gh-publishing'
$AppId = '<YOUR-GITHUB-APP-ID>'
$InstallationId = '<YOUR-GITHUB-INSTALLATION-ID>'
$WorkspaceId = '<YOUR-EXISTING-LOG-ANALYTICS-RESOURCE-ID>'
$WorkspaceLocation = 'australiaeast'
$ActionGroupId = '<YOUR-APPROVED-ACTION-GROUP-RESOURCE-ID>'
$Organization = 'ExamplePublishing'
$Compiled = Join-Path ([IO.Path]::GetTempPath()) ('publishing-review-' + [guid]::NewGuid())
New-Item -ItemType Directory -Path $Compiled | Out-Null
az bicep build --file infra\runner.bicep --outfile "$Compiled\foundation.json"
if ($LASTEXITCODE -ne 0) { throw 'Foundation compilation failed.' }
az bicep build --file infra\runner-alerts.bicep --outfile "$Compiled\alerts.json"
if ($LASTEXITCODE -ne 0) { throw 'Monitoring compilation failed.' }
# Confirm the proposed resource/role scope and recurring charges before create.
az deployment group what-if --subscription $SubscriptionId `
  --resource-group $ResourceGroup --template-file infra\runner.bicep `
  --parameters githubAppId="$AppId" githubInstallationId="$InstallationId" `
    alertActionGroupId="$ActionGroupId"
if ($LASTEXITCODE -ne 0) { throw 'Foundation what-if failed.' }

Create the named resource group first if it does not exist, after choosing the region and subscription: az group create --subscription $SubscriptionId --name $ResourceGroup --location australiaeast. Check its exit status. Foundation deployment below is incremental; never use complete mode as retirement:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$foundation = az deployment group create --subscription $SubscriptionId `
  --resource-group $ResourceGroup --name publishing-foundation `
  --mode Incremental --template-file infra\runner.bicep `
  --parameters githubAppId="$AppId" githubInstallationId="$InstallationId" `
    alertActionGroupId="$ActionGroupId" --output json | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) { throw 'Foundation deployment failed.' }
$VaultName = $foundation.properties.outputs.keyVaultName.value
$IdentityId = $foundation.properties.outputs.automationPrincipalId.value
$StateAccount = $foundation.properties.outputs.stateAccountName.value
$foundation.properties.outputs | ConvertTo-Json -Depth 10

Expected outputs are Automation ID/identity, generated storage/vault names, runtime name, nonsecret configuration and three schedule names. No VM/network resource should be proposed. The foundation does not create schedules or load a private key. Supply an existing Log Analytics workspace and approved action group; these are not created by this implementation.

Perform the App/secret approval steps below before publishing. The secret-loading operator needs existing Key Vault secret-write rights; Automation’s Secrets User assignment is read-only. Load an approved local PEM file directly, never its contents through command arguments:

1
2
3
4
$ApprovedPemPath = '<YOUR-APPROVED-LOCAL-PEM-FILE>'
az keyvault secret set --subscription $SubscriptionId --vault-name $VaultName `
  --name github-app-private-key --file $ApprovedPemPath --output none
if ($LASTEXITCODE -ne 0) { throw 'Approved secret load failed.' }

First publish bundles with -ContentOnly: this performs Azure writes, but changes no schedule/link. Then use Automation account > Runbooks > Test-RunnerReadiness > Start, inspect Completed plus sanitized streams, and check that the ledger is absent/idle or a supported finished hosted cycle. A probe refusing active/legacy deployment state is not permission to overwrite it.

1
2
3
.\scripts\Publish-RunnerRunbooks.ps1 -SubscriptionId $SubscriptionId `
  -ResourceGroup $ResourceGroup -AutomationName $AutomationName -ContentOnly `
  -WorkspaceResourceId $WorkspaceId -WorkspaceLocation $WorkspaceLocation

Expected output: Three hosted runbook bundles verified; schedules/links unchanged. This publisher verifies its returned content after LF normalization and trimming terminal whitespace; it is not a cryptographic live-byte attestation. Role propagation or missing credentials can delay readiness: inspect errors and fix permissions, never fall back to a PAT.

Enable diagnostics only while keeping alert rules disabled. Derive the union of publication weekdays from the actual config, not another hand-maintained list:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$base = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.Automation/automationAccounts/$AutomationName"
$asset = az rest --method get --url "$base/variables/RunnerConfig?api-version=2024-10-23" `
  --output json | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) { throw 'Config read failed.' }
. .\runbooks\Runner.Common.ps1
$config = ConvertFrom-RunnerConfig $asset.properties.value
$DueDays = @($config.workflows.publishDays | Sort-Object -Unique)
$ActivationUtc = '<YOUR-INTENDED-ACTIVATION-UTC-ISO-TIMESTAMP>'
$params = Join-Path $Compiled 'monitor-parameters.json'
@{ parameters = @{
  automationName = @{ value = $AutomationName }
  workspaceResourceId = @{ value = $WorkspaceId }
  workspaceLocation = @{ value = $WorkspaceLocation }
  actionGroupResourceId = @{ value = $ActionGroupId }
  diagnosticsEnabled = @{ value = $true }
  monitoringEnabled = @{ value = $false }
  dueWeekdays = @{ value = $DueDays }
  heartbeatStartUtc = @{ value = $ActivationUtc }
} } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $params -Encoding utf8
az deployment group create --subscription $SubscriptionId --resource-group $ResourceGroup `
  --name publishing-monitoring --template-file infra\runner-alerts.bicep `
  --parameters "@$params" --output none
if ($LASTEXITCODE -ne 0) { throw 'Diagnostics-only deployment failed.' }

In Log Analytics workspace > Logs, verify AzureDiagnostics contains the readiness job’s MICROSOFT.AUTOMATION/JobLogs records. The readiness probe is deliberately excluded from failure metrics. In Azure Monitor > Action groups, test the approved receiver and obtain recipient confirmation. Run the template’s absence query with controlled timestamps for a due weekday after 07:15, an off-day and before-grace; record actual results without claiming this alone proves publication.

Now supervise the hosted sites with App auth and today’s real date. This starts publication and requires your explicit approval; do not run it as a validation shortcut:

1
2
.\scripts\Invoke-SupervisedPublishing.ps1 -SubscriptionId $SubscriptionId `
  -Repository blog,ai-strategy-blog -ConfirmSupervised

The helper prints a cycle UUID and local date. For each repository, use gh run list --repo "$Organization/blog" --workflow azure-static-web-apps-red-flower-083307e00.yml --event workflow_dispatch --branch main --json databaseId,displayTitle,status,conclusion (and the analogous strategy workflow). Select exactly one Azure daily publish / <printed UUID> result, inspect its hosted jobs and publication-check output with gh run view <RUN-ID> --repo <ORG/REPO> --log. A no-posts-due Sunday validates homepage/future checks, not due-day release or unattended control. Never retry an ambiguous helper POST blindly.

The helper currently sorts/deduplicates requested repository names and sends direct App dispatches; it does not wait for terminal results, write production state or perform content checks itself. The operator must observe those results.

For activation, select the next future Sydney 00:30 using the named timezone. Do this during Sydney daytime: -MigrateHosted refuses 00:00-07:00. Its name is retained even for a fresh hosted installation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$zone = [TimeZoneInfo]::FindSystemTimeZoneById(
  $(if ($IsWindows) { 'AUS Eastern Standard Time' } else { 'Australia/Sydney' }))
$local = [TimeZoneInfo]::ConvertTime([datetimeoffset]::UtcNow, $zone)
$next = [datetime]::SpecifyKind($local.Date.AddDays(1).AddMinutes(30),
  [DateTimeKind]::Unspecified)
$DailyStart = [datetimeoffset]::new($next, $zone.GetUtcOffset($next))
.\scripts\Publish-RunnerRunbooks.ps1 -SubscriptionId $SubscriptionId `
  -ResourceGroup $ResourceGroup -AutomationName $AutomationName `
  -WorkspaceResourceId $WorkspaceId -WorkspaceLocation $WorkspaceLocation `
  -DailyStartTime $DailyStart -MigrateHosted

This is the activation step, not a preview: it performs readiness, verifies all three bundles, links schedules, deploys enabled alerts, enables/verifies schedules and retires legacy Watchdog0-3 schedule/link records if present. It never provisions/deletes a VM. It preserves a pre-existing daily start only when its recurrence/local time match; inspect its actual next run. It preserves a valid pre-existing absence-alert activation marker instead of postponing monitoring on every update.

Read back schedules and links with az rest --method get --url "$base/schedules?api-version=2024-10-23" and "$base/jobSchedules?api-version=2024-10-23". Require the exact three enabled daily schedules and one link each. Inspect both enabled Azure Monitor rules’ actions, scopes, frequency and derived weekdays. Only then set AZURE_DAILY_PUBLISHING_ENABLED=true under each repository > Settings > Secrets and variables > Actions > Variables, or via approved gh variable set AZURE_DAILY_PUBLISHING_ENABLED --body true --repo <ORG/REPO>. The controller does not check this variable; the workflows suppress fallback cron.

For an existing foundation, do not redeploy it just to change config: snapshot and update RunnerConfig through the variable API, preserving encoding/credential pointers. A cadence change also requires monitoring refresh with -MigrateHosted; -ContentOnly does not refresh derived alert weekdays.

Manual approval and credential custody

These are decisions an unattended script must not infer. In organization settings > Developer settings > GitHub Apps, approve a repository-scoped App with Actions: read/write, implicit Metadata: read, and Variables: read for the documented operator/configuration policy. Install it only on the two publishing repositories. Record its App ID and installation ID as nonsecret deployment parameters. The controller’s active-workflow preflight reads Actions, not repository variables, and no organization runner permission is required.

  1. An organization administrator approves an organization-owned GitHub App and installs it only on the two publishing repositories. The App needs dispatch/correlation and configuration-read permissions. Self-hosted runner administration is not required. In this installation, that permission was granted for the VM design and has not yet been revoked; removing it is an outstanding manual settings cleanup, not an execution dependency.
  2. The credential owner generates the App private key and loads it directly into the dedicated Key Vault secret using an approved identity. Do not put it in source, parameter files, logs or chat. Do not substitute an interactive personal token when unattended App authentication fails.
  3. The Azure owner approves managed-identity access scoped to the required secret and state resources. VM control and Run Command permissions are not needed.
  4. Repository owners verify existing Static Web Apps deployment secrets. In this installation, native-cron suppression remains enabled in both repositories, and the old runner-routing variables have been removed.
  5. The monitoring owner approves ingestion costs and the action-group receiver. Test alert behavior and verify actual recipient receipt; Azure test status Succeeded alone is weaker evidence.
  6. The operator enables the dispatch and monitoring schedules only after supervised acceptance. Record the actual first unattended due-day result separately.

Real-World Impact

The useful change is moving responsibility for the timed trigger without taking responsibility for a runner fleet. Run correlation and content verification still matter: simplifying execution infrastructure does not make an accepted dispatch equivalent to a successful publication.

What is established, and what is not

As of November 8, the following is verified:

  • App-authenticated supervised dispatches succeeded for both sites: blog run 42454168841 at 44df268 and AI Strategy run 42454170756 at 8e6fbde. Every executed job ran in the GitHub Actions hosted runner group on ubuntu-latest.
  • November 8 was a Sunday with no due posts. The deployed homepage matched the local build, and future-content exclusions passed.
  • The three Sydney schedules are enabled, both alerts are enabled, and the four old watchdog schedules and their links are deleted.
  • The VM and its dedicated resources are deleted, and the organization runner and runner group no longer exist.

What is not established: the supervised runs used direct dispatch, not an unattended controller cycle. The first unattended due-day cycles are Tuesday, November 10 for the blog and Wednesday, November 11 for AI Strategy. They have not yet happened, so no unattended reliability claim is made.

Cost and operational scope

Hosted execution avoids owning the build machine, but it is not automatically free. The estimated ~61 hosted minutes per month shares the Free plan’s included allowance with every other private workflow. Azure Automation and monitoring are an indicative A$2-6 per month, depending on overages and the shared Log Analytics workspace. That figure is an estimate, not an actual bill or a cap.

Retirement deleted the VM, OS disk, NIC, static public IP, virtual network, NSG, the 06:55 shutdown schedule, and the dedicated VM role definition and assignment. Each returned HTTP 404 on independent re-query. App-authenticated queries also returned 404 for the organization runner and runner group. The Automation account, state storage, Key Vault and shared monitoring remain.

Deallocating a VM alone would have left disk and public IP charges. Their list price was approximately A$8.68 per month; that is an avoided list-price cost, not measured savings.

Gotchas and Edge Cases

The smaller architecture still crosses cloud control planes. Recovery must preserve publication truth and dispatch truth, not just reduce the number of resources.

A better trigger is not a real-time guarantee

Azure scheduling, GitHub API availability, hosted-runner capacity and deployment propagation remain possible delay sources. Measure the complete due-day cycle against the Sydney deadline. Do not promise that moving cron creates a hard service-level guarantee.

Sydney time and weekday selection

Use Australia/Sydney, not a permanent UTC offset. DST changes both local midnight and the UTC representation of the publication window. Revalidate publication date after queue waits. A build crossing midnight must not quietly use a stale requested date.

Dispatch ambiguity and concurrency

A timeout can leave uncertainty about whether a dispatch was accepted. The code persists ambiguous before POST, never retries that POST blindly, and discovers exactly one matching run; duplicate matches or a search exceeding 250 runs fail. Never mint a replacement cycle or select an unrelated run just to get a green result.

Production concurrency can affect pending runs independently of dispatch acceptance. Observe the exact terminal result, including replacement/cancellation, and explain the final workflow’s configured behavior.

Content verification remains essential

A no-due-page day can legitimately verify the homepage. A missing due page cannot silently become a homepage-only check. Keep future-content exclusions intact in scheduled, push and manual paths. Faster or earlier triggering is not permission to build tomorrow’s article today.

Monitoring is evidence, not configuration

Verify ingestion, alert query behavior, due-day selection and receiver delivery. The absence query begins after 07:15 on a due weekday, looks for a completed Stop job since 06:30, and evaluates hourly. Ingestion/evaluation/delivery can delay notification beyond 08:15. A completion signal must represent the correlated run and content result, not simply successful execution of a dispatch request.

Best Practices

Keep the hosted workflow path that already worked. Use the external controller to solve the measured scheduling problem, then validate the entire due-day path before suppressing the fallback.

Acceptance and rollback

For reader-local site checks, run npm test in the Hugo root with Node.js 22, Hugo 0.152.2 extended and Go 1.24.x on PATH; run hugo --minify with normal production settings. In the strategy root use Node.js 20, npm ci, npm test and npm run build with the existing lockfile/templates. Each external-command failure stops acceptance. These tests include temporary fixtures; they do not dispatch or deploy.

The source regression suites are custom PowerShell test harnesses, not the Pester module. The pinned Automations tests\runner.tests.ps1 and tests\runner-template.tests.ps1 validate production helpers and compiled foundation/monitoring with mocked transports. The full source suite also reads the historical teardown helper for static/mocked tests: it is excluded from this article and is not a reader deployment prerequisite. Article-specific commands require static review and supervised execution in the reader’s environment; local parsing does not establish cloud deployment success.

Record App readiness, exact UUID/run IDs, every due-page content result, off-day behavior, invalid-date rejection, dispatch ambiguity handling and monitoring receiver delivery. A successful deadline controller must emit Verified hosted publication: <UUID>. No-op entrypoints do not emit deployment success. Observe the first real unattended Tuesday/Wednesday separately from Sunday supervised acceptance.

For hosted rollback, first disable SydneyDaily0030 and PublishingRecovery0130 through Automation account > Schedules to prevent new dispatch. Leave deadline verification/alerts until existing runs resolve, inspect persisted state and do not erase unknown run IDs. Restore native cron by setting AZURE_DAILY_PUBLISHING_ENABLED=false (or removing it) in both repositories; manually dispatch the hosted workflow with no cycle/date inputs if urgent. Keep the normal date/content guards. Restoring a deleted VM is not rollback for this hosted design.

Both workflows resume native cron whenever AZURE_DAILY_PUBLISHING_ENABLED is anything other than true. Rollback must not advance content dates or erase ambiguous-cycle evidence. The old runner variables no longer exist and are not rollback controls.

Retire a VM, runner and group only after a verified hosted cutover and explicit retirement authorization; that sequence was followed here. Any residual permissions, such as this installation’s unrevoked App runner permission, need a scoped manual review. The one-time VM retirement procedure is historical operator cleanup, not part of the implementation guide.

🍺
Brewed Insight: Fix the component the evidence implicates. A delayed cron trigger justified independent scheduling; it did not, by itself, justify owning a runner VM. Keep hosted execution, then prove the exact due content reached readers.

Learn More

Platform references explain the design’s dependencies. They explain platform behavior; all new deployable files are inline.

Source versions and limits: The code in this guide comes from three private repositories at these commits: BrewedInTheCloud/Automations@234db39e4b80a67954a4c4c9f4088aa6b504d776 (the Azure Automation controller and infrastructure), BrewedInTheCloud/blog@44df268d5151548f6aecc9556add3ee5eea0ae16 (the Hugo site) and BrewedInTheCloud/ai-strategy-blog@8e6fbde91b5dbaf0608c6df0a5e5b5e694244405 (the Eleventy site), with the placeholders listed in the reader deployment commands. The evidence is the November 5 incident review plus the November 8 hosted cutover and VM retirement checks. Unattended due-day results and actual billing are not available yet, and removing the GitHub App’s organization runner permission remains a manual follow-up. This post is the complete reference; there is no separate repository or download.