mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
ef6e784c2e
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
354 lines
21 KiB
Markdown
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
|