Files
pulse/.github/workflows/README.md
T
pulse-triage[bot] ef6e784c2e docs(release): clarify combined continuity incident recovery
Fresh stable readback still shows mutable v6.4.1 alongside orphan v6.4.2. Explain forward supersession without deleting historical tags or treating frontier success as release admission. Cover that distinction with a focused recovery regression; publication authority and all existing gates remain unchanged.

Change-source: pulse-maintainer
2026-09-05 20:12:48 +01:00

354 lines
21 KiB
Markdown

# GitHub Actions Workflows
## Trust contract
`scripts/check_workflow_trust.py` validates every workflow during the script
smoke suite. Remote actions and reusable workflows must use full commit SHAs,
container actions must use SHA-256 digests, and GitHub-hosted runners must use
dated image labels rather than moving `-latest` aliases.
The audit also requires trust-bearing workflow structure to remain directly
visible in canonical block YAML. Escaped or explicit mapping keys,
anchors/aliases/tags, non-empty flow mappings, inline `jobs` or `steps`, and
noncanonical job declarations are rejected because YAML expansion can
otherwise hide permissions, runner identity, dependencies, or executable
steps from lexical policy checks.
Checkout pins additionally belong to a reviewed allowlist whose current floor
includes GitHub's fail-closed fork-PR protection for privileged events. The
audit rejects `pull_request_target` except for the exact, metadata-only closed-PR
capacity reclaimer, and rejects checkout's `allow-unsafe-pr-checkout` opt-out.
That exception can only run the protected default-branch helper, has no shell,
cache, artifact, container, secret, or pull-request checkout ingress, and is
machine-checked down to its permissions, dependencies, and generated program.
JavaScript action pins that previously selected Node 20 also belong to a
reviewed Node 24 allowlist. Release-candidate artifact downloads use
`actions/download-artifact` v8.0.1 and issue automation uses
`actions/github-script` v8.0.0; the audit rejects a return to their Node 20
pins before GitHub removes that runtime from hosted runners on 23 September
2026.
`workflow_run` is also a privileged trigger. Every handler must filter its
upstream workflow to the literal canonical branch `main`, and checkout steps
must not select code through triggering-run head metadata. These handlers also
cannot download upstream workflow artifacts or reacquire repository code with
command-line Git/GitHub clients or Actions artifact APIs. Upstream artifacts
remain untrusted data; move any future artifact consumer behind an independently
authenticated, non-`workflow_run` handoff before privileged use.
Every `actions/checkout` step must also set `persist-credentials` explicitly.
Use `false` unless a later command in the same job performs an authenticated
Git write. The small number of write-path exceptions use `true` with the
machine-checked `# required: authenticated git writes` rationale.
Every job that executes on a runner declares one literal `timeout-minutes`
budget. Reusable-workflow caller jobs cannot set a timeout and delegate that
responsibility to each runner job in the called workflow. The trust check
rejects omitted, dynamic, duplicate, zero, or platform-invalid budgets so a
stalled check or publisher cannot silently occupy a runner for GitHub's
six-hour default.
Each workflow declares its default `GITHUB_TOKEN` permissions explicitly, and
both workflow defaults and job-level overrides enumerate scopes instead of
using `read-all`, `write-all`, or dynamic grants. Workflow inputs, secrets,
`github.token`, step and job outputs, and attacker-controlled GitHub event
metadata are passed to `run` steps through `env`; they are data and must never
be interpolated into the generated shell program. Outputs remain data even
when an intermediate step parsed or validated them, because later substitution
would turn their value back into shell source.
The same boundary applies to action inputs that are themselves executable
source. The audit recognizes the script inputs of `actions/github-script`,
`azure/cli`, and `azure/powershell` and rejects workflow or event data embedded
in those programs. Pass the value through step `env` and read it from the
script's process environment instead.
Jobs that receive repository secrets or a write-capable
`GITHUB_TOKEN` do not restore or save caches. This includes setup-action
dependency caches, direct Actions caches, and external BuildKit cache imports:
cache contents are unsigned mutable build input, while provenance only records
what the workflow produced. Read-only jobs may still cache locked dependencies.
Those privileged jobs also run only on a reviewed, literal GitHub-hosted image.
Persistent self-hosted runners can retain executable state from an earlier job,
and dynamic runner expressions can silently move a credential boundary. Local
self-hosted acceleration therefore remains free of repository secrets and
read-only.
Passing data through `env` does not make it safe to append to the runner's
`GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, or `GITHUB_STATE` command files.
The audit follows workflow data and values read from the event payload through
local Bash and PowerShell assignments before rejecting command-file writes, so
renaming a value is not mistaken for validation. Use
`scripts/write_github_output.py` for output data: it validates the output name
and chooses a random multiline delimiter that cannot collide with the value.
This prevents embedded newlines from creating additional outputs or
environment entries.
Workflows triggered by `pull_request` cannot reference repository
secrets. Canonical governance therefore keeps its pull-request checks local to
the public checkout. `canonical-private-governance.yml` performs cross-repo
status, control-plane, subsystem-registry, subsystem-contract, mobile
compatibility, and repo-governance checks only after a push to `main`, so
unmerged pull-request code cannot replace the instructions that receive
`WORKFLOW_PAT`. The public job still audits contract structure and every public
path; only private path existence is deferred to that credential-isolated job.
Public configuration is not exempt: pull-request workflows must use repository
variables or checked-in values rather than the secret context.
## Release Continuity
The `security-scan.yml` backstop checks the latest stable release lock and
activation identity every six hours. Its weekly run and an immediate read-back
after every stable release convergence perform the full verification from the
public surfaces customers use. They bind the immutable
GitHub release and activation marker to one source commit, authenticate every
checksummed release asset and SSH signature, re-verify release and build
attestations, and require the exact Docker Hub, GHCR, and OCI Helm identities
to remain equal to the digests committed at activation. Stable Docker Hub and
GHCR discovery aliases (`latest`, major, and major-minor) must also retain
those identities. Every run requests 90-day retention for a machine-readable
evidence packet, including partial outcomes when a check fails; GitHub applies
the repository's configured retention maximum and reports any clamp in the
workflow warning. Six-hour lock-watch evidence explicitly records its narrower
`release_lock` mode and skipped full-surface checks. The job is read-only and
requires the public `PULSE_UPDATE_SIGNING_PUBLIC_KEY` repository variable. A
failed release-trust check still permits activation-marker inspection when the
tag, numeric release ID, and exact source SHA are structurally valid. This
exposes independent marker damage in the same evidence packet; it never admits
the release or enables later delivery checks unless both trust checks pass.
Scheduled and push-time npm audits classify JSON results, use one-minute
registry attempts, and retry an unavailable audit endpoint. Advisory findings
still fail immediately, even if the same response contains a transport error. The
scheduled security scan and dependency-changing builds fail if three attempts
produce no result; a build with an unchanged dependency graph warns and uses
the base commit's passing answer. Audit steps defer their aggregate verdict so
an unavailable endpoint cannot suppress independent frontend checks or the
production bundle build; the preceding clean install disables npm's duplicate
best-effort audit request.
Activation inspection also requires exactly one uploaded marker and compares
the downloaded byte count and SHA-256 value with GitHub's release-asset
metadata, so a valid-looking JSON response cannot silently replace or truncate
the packet that the release advertises.
`stable-install-continuity.yml` complements those byte-identity checks with a
weekly reinstall of the advertised stable release from its public assets. It
admits only the immutable stable identity accepted by the same continuity
validator, calls the release install-and-boot smoke under a read-only token,
and verifies the live service health and exact version. The privileged systemd
smoke environment is digest-pinned because a floating container image would
otherwise be an unreviewed code path inside the release gate.
The shared `install-sh-smoke-body.yml` inherits its caller's token permissions;
keep it free of workflow- or job-level permission overrides. Continuity calls
that body directly with `contents: read`. The existing `install-sh-smoke.yml`
manual/release entry point retains `contents: write` for unpublished draft asset
GETs and forwards the same inputs to the body. Calling that draft-capable entry
point from read-only continuity prevents workflow admission before any job runs.
### When advertised stable is mutable
Treat a failed `immutable == true` check as release-integrity debt, not proof
that a downloaded binary is compromised or that the customer runtime is down.
Record the advertised tag, release ID, source identity and failed check separately
from installed health. A successful unrelated workflow does not clear it.
The repository setting checked by `scripts/check-github-release-immutability.sh`
applies to **future** publications only; it does not repair an already published
mutable release. GitHub documents this boundary in
[Preventing changes to your releases](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/establish-provenance-and-integrity/prevent-release-changes).
Do not disable the continuity assertion, reset incident state, replace historical
assets, or delete/recreate a published release to obtain a passing result.
Recovery belongs to the governed release path: qualify a replacement candidate,
retain its exact source and artifact identities, complete the required clean
soak, and obtain approval of the exact stable packet under the
[Release Train policy](../../docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md#release-train).
An immutable prerelease alone does not satisfy those gates. After authorised
stable activation, require the continuity read-back for the newly advertised
identity; enabling the setting or changing a pointer is not verification.
Installed customer journeys, scheduled health outcomes and notification
receipt/recovery evidence remain separate checks. This procedure grants no
publication or deployment authority.
### When mutable stable and newer orphan tags coexist
Retain the continuity run's release, frontier and activation diagnostics together.
A mutable advertised release, a newer source tag without a release packet, and
missing activation digests are independent failures. Fixing one does not clear
the others. Record the exact orphan versions and registry names, not just the
latest RC; an immutable RC does not replace the advertised stable identity.
Use this decision sequence within the existing release authority:
1. Inspect the failed publication and convergence owners before proposing a
retry. Retry convergence only for an already verified immutable activation
packet with its original identities. It cannot supply missing assets or
digests to a published packet, or turn an orphan tag into a qualified release.
2. For a replacement, choose an unused stable version newer than the exposed
orphan stable versions, with its own qualified candidate. Preserve the old
evidence; do not delete or retag the orphan merely to turn the scan green.
Frontier validation compares versions against advertised stable: after a
later replacement, older orphan tags no longer fail that specific check.
This is supersession, not evidence that the old publication was successful
or that customers pinned to its container version have migrated.
3. Keep patch scope to named regression/security fixes. A changed patch RC
restarts 72 hours of clean soak; an older immutable RC or green main cannot
lend its qualification to changed bytes. Retain the exact candidate packet
for founder approval before stable publication.
4. After authorised publication and convergence, require all release, frontier,
activation and provenance checks for the newly advertised identity. A
passing frontier alone can coexist with a mutable release and is not
admission. Retain exact digest/alias readbacks and installed upgrade,
rollback, health and receiver evidence separately; neither source tests nor
tag supersession prove those journeys.
If a previously qualified stable target is available, any authorised pointer
rollback is containment only: newer orphan tags can still fail frontier
validation. If no qualified fallback exists, record that dependency rather
than presenting the old mutable release as trusted. Do not bypass the check
to unblock unrelated security results; skipped checks remain unproved.
Future release candidates also carry
`release-build-provenance.sigstore.json`, produced by the hosted
`build-release-candidate.yml` job after complete candidate validation. The
bundle is covered by the immutable candidate manifest and lets consumers
verify downloaded files offline against the candidate-builder identity.
## Issue Triage Automation
**Files**:
- `issue-version-label-sync.yml`
- `issue-version-retest-comment.yml`
Issue intake is split deliberately:
- `issue-version-label-sync.yml` is the silent metadata path. It runs on `opened`, `edited`, and `reopened` issue events so version labels, `needs-version-info`, `needs-retest-on-latest`, and the explicit `needs-decomposition` topic-integrity signal stay correct when maintainers tidy issue metadata.
- `issue-version-retest-comment.yml` is the scheduled public guidance path. It gives maintainers a grace window, then posts reporter-facing retest guidance only when an older-version bug report from a non-maintainer has no existing maintainer response. This prevents generic stable-version advice from contradicting a specific maintainer fix or prerelease boundary.
- Both workflows load the shared helper at `.github/scripts/issue-version-triage.cjs` so parsing and classification logic lives in one place instead of drifting across duplicated inline scripts.
- `needs-decomposition` is driven only by the structured **Additional actionable topics** form field. The [triage contract](../../docs/ISSUE_TRIAGE.md) requires human or agent judgment to create linked dispositions; the workflow does not infer or auto-create issues from free text.
## Update Demo Server
**File**: `update-demo-server.yml`
Automatically updates the governed demo target after a release is published.
Stable releases update the public demo. Prerelease tags no longer update a
separate v6 preview demo after GA.
### Configuration Required
Create one GitHub Environment:
1. `demo-stable`
The environment must define the secret names used by the governed demo target.
Required environment secrets:
1. **DEMO_SERVER_SSH_KEY**
- The private SSH key for accessing the demo server
- Generate with: `cat ~/.ssh/id_ed25519` (or your key file)
- Should be the full private key including `-----BEGIN` and `-----END` lines
2. **DEMO_SERVER_HOST**
- The hostname or IP of the demo server
3. **DEMO_SERVER_USER**
- The SSH username for the demo server (e.g. `root` or a deploy user with sudo access)
Required shared secret:
1. **TS_OAUTH_CLIENT_ID** and **TS_OAUTH_SECRET**
- Tailscale OAuth client (business tailnet `tawny-powan.ts.net`, scope Auth Keys write, tag `tag:infra`) used by the governed demo deploy/update workflows before SSH
- The action mints an ephemeral, pre-authorized, tagged node key per run, so runners join and garbage-collect themselves; unlike the retired static `TS_AUTHKEY`, the OAuth secret does not expire every 90 days
- Allows GitHub-hosted runners to reach private demo targets such as the stable `pulse-relay` Tailscale host
- May be stored as repository secrets or repeated in the selected environment if desired
Required environment variables:
1. **DEMO_EXPECTED_HOSTNAME**
- The remote `hostname` value the stable demo environment is expected to report
- Stable example: `pulse-relay`
- This is a host-identity guard: the workflow fails closed if the SSH secret points at the wrong machine
2. **DEMO_LOCAL_BASE_URL**
- Local URL used on the target host for version and mock-mode verification
- Example stable value: `http://localhost:7655`
3. **DEMO_PUBLIC_HEALTH_URL**
- Public health endpoint for the stable demo target
- Example stable value: `https://demo.pulserelay.pro/api/health`
Optional environment variables:
1. **DEMO_SERVICE_NAME**
- Stable default: `pulse`
- When set, the server installer derives the instance-specific install dir,
config dir, update helper, and update timer from this service identity.
2. **DEMO_AUTH_USER** / **DEMO_AUTH_PASS**
- Demo credentials used for post-update mock verification
- Defaults to `demo` / `demo` when omitted
### How It Works
1. **Trigger**: Runs from the lease-owning Release Convergence workflow after an exact activation marker is committed
2. **Target serialization**: Enters the bounded FIFO queue for the shared `stable-demo-runtime` concurrency lock also used by emergency recovery; GitHub environment admission alone does not serialize work on the host, and the default single-pending concurrency mode would discard superseded pending operations
3. **Target selection**: Stable tags deploy to `demo-stable`; prerelease tags are skipped because the public v6 preview target is retired after GA
4. **Service identity**: Stable runs default to the `pulse` service identity
5. **Governance check**: Validates the selected tag is reachable from the governed release branch for that version
6. **Latest check**: Refuses to update the public demo unless the published tag is the latest stable release
7. **Network attach**: Joins Tailscale before any SSH step so governed demo targets can stay on private hostnames or Tailscale IPs
8. **Update**: SSHs to the selected demo host and runs the tag-matched root installer from that exact git tag
9. **Host identity check**: Verifies the SSH target reports the governed expected hostname before running installer or deploy steps
10. **Verify**: Checks that the new version is running, mock mode is active, and the public demo HTML serves the same frontend entry asset as the target service
11. **Browser smoke**: Uses the governed Playwright helper to prove the public demo still renders the login shell in a real browser
12. **Cleanup**: Removes SSH key from runner
### Testing
Use `Release Dry Run` for the governed no-mutation demo-path preflight, or run
`Verify Demo Server` to verify the current committed stable target manually.
### Benefits
- ✅ The public demo follows the stable v6 release line after GA
- ✅ Prereleases no longer require a second public v6 preview surface
- ✅ Validates the real server installer path on the selected target
- ✅ Removes release-operator guesswork about which demo should move
## Verify Demo Server
**File**: `deploy-demo-server.yml`
The former branch-build deploy path is retired because it could replace the
stable public demo with unreleased `main`. Its manual dispatch is now a
non-mutating wrapper that verifies the current committed stable target. All
stable demo writes require an exact stable tag and activation marker and run
from `release-convergence.yml` under the global customer-promotion lease.
## Helm CI
**File**: `helm-ci.yml`
Runs `helm lint --strict` and renders the chart with common configuration combinations on every pull request that touches Helm content (and on pushes to `main`). This prevents regressions before they land.
- Triggered by PRs/pushes touching `deploy/helm/**`, docs, or the workflow itself
- Uses Helm v3.15.2
- Renders both the default deployment and an agent-enabled configuration to catch template issues
## Publish Helm Chart
**File**: `publish-helm-chart.yml`
Packages the Helm chart and pushes it to the GitHub Container Registry (OCI) whenever a GitHub Release is published. Also makes the packaged `.tgz` available as both an Actions artifact and a release asset. The same behaviour can be triggered locally via `./scripts/package-helm-chart.sh <version> [--push]`.
- Triggered automatically on `release: published`, or manually via workflow dispatch (requires `chart_version` input)
- Chart and app versions mirror the Pulse release tag (e.g., `v4.24.0``4.24.0`)
- Publishes to `oci://ghcr.io/<owner>/pulse-chart`
- Verifies the pushed OCI chart can be read from GHCR without registry credentials
- Requires no additional secrets—uses the built-in `GITHUB_TOKEN` with `packages: write` permission