From 948d5705b1f9cc337382ca0a14556777f86087e9 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 03:38:22 +0100 Subject: [PATCH 1/7] Disable automatic staging gate release trigger Signed-off-by: Chris0Jeky --- .github/workflows/cd-staging-gate.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/cd-staging-gate.yml b/.github/workflows/cd-staging-gate.yml index 3d1d1744c..8c6dc4e39 100644 --- a/.github/workflows/cd-staging-gate.yml +++ b/.github/workflows/cd-staging-gate.yml @@ -4,7 +4,6 @@ # (docs/ops/DEPLOYMENT_WORKFLOW.md) with a manual approval gate for Phase 3. # # Triggers: -# - Release published (automatic) # - Manual workflow dispatch (for re-runs or pre-release validation) # # ADR: ADR-0028 (Staged Deployment — Blue/Green with Canary Verification) @@ -25,9 +24,6 @@ on: required: false type: boolean default: false - release: - types: - - published permissions: contents: read From 1ec17437a1a6c04de2feba95b456baf3e1af8fd3 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 03:44:49 +0100 Subject: [PATCH 2/7] Sync manual staging gate documentation Signed-off-by: Chris0Jeky --- docs/IMPLEMENTATION_MASTERPLAN.md | 4 ++++ docs/STATUS.md | 4 ++-- docs/ops/DEPLOYMENT_WORKFLOW.md | 4 ++-- docs/ops/README.md | 2 +- docs/strategy/02_PACKAGING_DISTRIBUTION_STRATEGY.md | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index b37d752aa..43c799816 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -10,6 +10,10 @@ Companion Active Docs: - `docs/MANUAL_TEST_CHECKLIST.md` - `docs/GOLDEN_PRINCIPLES.md` +## Delivery update (2026-07-27, release workflow truth) + +- **Manual-only parked staging gate (`#1228`):** `.github/workflows/cd-staging-gate.yml` no longer subscribes to `release: published`; its required-input `workflow_dispatch` path, jobs, permissions, and manual `production` environment approval remain available for deliberate operator runs. Active beta release lanes (`release-desktop.yml`, `ci-release.yml`, and `release-security.yml`) are unchanged, so publishing a release cannot accidentally strand a staging run on a nonexistent environment. + ## Delivery update (2026-07-26, agentic governance) - **Failure-ledger projection gate (`#1492`):** Required Docs Governance now pins Python 3.12 and runs the existing JSONL↔Markdown synchronization unittest before the governance checks, so a JSONL-only change with stale generated Markdown fails Required CI without regeneration masking it. Local agentic update workflows use the distinct render-then-test order so hook-appended JSONL can be projected, and the smoke contract pins both sides of that distinction. diff --git a/docs/STATUS.md b/docs/STATUS.md index 59b269bbb..bbd43cefa 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -582,7 +582,7 @@ Eight parallel worktree agents delivered new features, security infrastructure, - **CLD-03 OAuth PKCE and account linking** (`#676`/`#812`): DB-backed auth code store replacing in-memory `ConcurrentDictionary` — `OAuthAuthCode` entity with EF migration, `IOAuthAuthCodeRepository` with atomic `TryConsumeAtomicAsync` (raw SQL `UPDATE WHERE IsConsumed = 0 AND ExpiresAt > now`); PKCE support via `UsePkce = true` in ASP.NET Core 8 OAuth middleware; account linking endpoints (`POST /api/auth/github/link`, `DELETE /api/auth/github/link`, `GET /api/auth/linked-accounts`) with conflict detection and session verification; frontend Linked Accounts section in `ProfileSettingsView` with Link/Unlink buttons and avatar display; 24+ backend tests; adversarial review fixed CSRF on account linking, TOCTOU in expiry check, JWT plaintext in DB, DoS via full-table load, and unbounded table growth **Ops & Observability:** -- **OPS-09 Staged deployment workflow** (`#101`/`#806`): ADR-0028 documents blue/green + canary deployment strategy with rollback criteria; `docs/ops/DEPLOYMENT_WORKFLOW.md` canonical 4-phase workflow (build verification → staging → production canary → production promotion) with rollback procedures, database migration safety, emergency hotfix override, and ownership/escalation model; `docs/ops/RELEASE_CHECKLIST.md` versioned smoke verification (7 pre-deploy + 9 automated staging + 7 manual staging + 7 canary + 6 post-promotion + 5 post-release checks) with failure response matrix; `scripts/deploy/smoke-test.sh` portable smoke test (9 automated checks: health, API, auth, board auth gate, frontend, SignalR, static assets, security headers, container restart detection); `.github/workflows/cd-staging-gate.yml` with `production` environment manual approval gate; adversarial review fixed script injection in CI workflow and unscoped container checks. _(⚠️ Parked by the 2026-06-13 archive pivot — staged cloud rollout de-scoped, see the parked-cloud note below. The workflow still auto-triggers on `release: published` and would hang on the non-existent `production` environment for the personal build; disabling that trigger is tracked in **#1228**.)_ +- **OPS-09 Staged deployment workflow** (`#101`/`#806`): ADR-0028 documents blue/green + canary deployment strategy with rollback criteria; `docs/ops/DEPLOYMENT_WORKFLOW.md` canonical 4-phase workflow (build verification → staging → production canary → production promotion) with rollback procedures, database migration safety, emergency hotfix override, and ownership/escalation model; `docs/ops/RELEASE_CHECKLIST.md` versioned smoke verification (7 pre-deploy + 9 automated staging + 7 manual staging + 7 canary + 6 post-promotion + 5 post-release checks) with failure response matrix; `scripts/deploy/smoke-test.sh` portable smoke test (9 automated checks: health, API, auth, board auth gate, frontend, SignalR, static assets, security headers, container restart detection); `.github/workflows/cd-staging-gate.yml` with `production` environment manual approval gate; adversarial review fixed script injection in CI workflow and unscoped container checks. _(⚠️ Parked by the 2026-06-13 archive pivot — staged cloud rollout de-scoped, see the parked-cloud note below. The workflow is manual-dispatch-only after **#1228**, so publishing a beta release no longer invokes the nonexistent `production` environment gate.)_ - **OBS-02 Error tracking and product analytics** (`#549`/`#811`): config-gated Sentry SDK for backend (`Sentry.AspNetCore` with `BeforeSend` PII scrubbing for emails/JWTs, `ServerName` blanked) and frontend; opt-in product telemetry service (`TelemetryEventService`) aligned with `docs/product/TELEMETRY_TAXONOMY.md` — property key allowlist (15 safe keys), max 10 properties, 200-char value truncation; `TelemetryController` with anonymous config endpoint and authenticated events endpoint; Plausible/Umami analytics script injection (`useAnalyticsScript`) with HTTPS-only URL validation; Pinia `telemetryStore` with consent management, event buffering, and flush; DNT/GPC privacy signal detection prevents auto-restore of consent; telemetry consent toggle in `ProfileSettingsView`; `docs/ops/OBSERVABILITY_SETUP.md` configuration guide; all telemetry opt-in and disabled by default; 38 backend + 25 frontend tests; adversarial review fixed Sentry PII leak, arbitrary properties injection, XSS via script URL, and DNT non-compliance ## Post-Merge Housekeeping (2026-04-12) @@ -1238,7 +1238,7 @@ Mutation testing workflow: `.github/workflows/mutation-testing.yml` - Backend Stryker.NET (Domain) + Frontend Stryker JS (captureStore/boardStore) - Non-blocking; HTML/JSON reports uploaded as 30-day artifacts -> _(Historical archive-period caveat, 2026-06-13–2026-07-10: release/tag-triggered distribution lanes were parked during the archive pivot. ADR-0044 supersedes that hold and reactivates only the public-beta release work admitted by `docs/REVIVAL_PLAN.md` (REVIVAL-07/14). Tag/release events still **auto-fire** these workflows: `release-desktop.yml` builds the cross-platform self-contained exe + publishes a GitHub Release on any `v*` tag push, and `ci-release.yml`/`release-security.yml` run on `v*` tags / `release: published`. The `release: published` event also triggers `cd-staging-gate.yml`, which then hangs on a missing `production` environment — disabling that trigger is tracked in **#1228**. SBOM/provenance (`reusable-sbom-provenance.yml`) remains a release-security artifact.)_ +> _(Historical archive-period caveat, 2026-06-13–2026-07-10: release/tag-triggered distribution lanes were parked during the archive pivot. ADR-0044 supersedes that hold and reactivates only the public-beta release work admitted by `docs/REVIVAL_PLAN.md` (REVIVAL-07/14). Tag/release events still **auto-fire** these workflows: `release-desktop.yml` builds the cross-platform self-contained exe + publishes a GitHub Release on any `v*` tag push, and `ci-release.yml`/`release-security.yml` run on `v*` tags / `release: published`. `cd-staging-gate.yml` is manual-dispatch-only after **#1228**, so those release events no longer enter its parked `production` environment gate. SBOM/provenance (`reusable-sbom-provenance.yml`) remains a release-security artifact.)_ Release workflow: `.github/workflows/ci-release.yml` _(release-triggered; container-image build/export is active only through REVIVAL-07's bounded beta-release scope; SBOM/provenance remains required)_ diff --git a/docs/ops/DEPLOYMENT_WORKFLOW.md b/docs/ops/DEPLOYMENT_WORKFLOW.md index 831cae787..97e0a3c17 100644 --- a/docs/ops/DEPLOYMENT_WORKFLOW.md +++ b/docs/ops/DEPLOYMENT_WORKFLOW.md @@ -290,9 +290,9 @@ Emergency overrides must still pass Phase 1 (CI build) and Phase 2 (staging smok The `cd-staging-gate.yml` workflow automates Phase 1 and Phase 2 gates: -> ⚠️ _(Parked by the 2026-06-13 archive pivot — staged cloud deployment de-scoped. The workflow **still auto-triggers on `release: published`** and then waits on a `production` environment that does not exist for the personal build, so the optional archival release would **hang** it. Disabling/gating that trigger is tracked in **#1228** — see `docs/ops/README.md`.)_ +> ⚠️ _(Parked by the 2026-06-13 archive pivot — staged cloud deployment de-scoped. After **#1228**, the workflow is manual-dispatch-only, so release publication cannot invoke its parked `production` environment gate. See `docs/ops/README.md`.)_ -- Triggers on release publish or manual dispatch +- Triggers only on manual dispatch with an explicit image tag - Builds and verifies container images - Runs the smoke test suite against a CI-hosted staging environment - Requires manual approval (GitHub environment protection) before Phase 3 can proceed diff --git a/docs/ops/README.md b/docs/ops/README.md index 81b3a55d3..d33d83fa9 100644 --- a/docs/ops/README.md +++ b/docs/ops/README.md @@ -4,7 +4,7 @@ This folder contains deployment, observability, and human-operator runbooks. ## Deployment -- `DEPLOYMENT_WORKFLOW.md` — Staged blue/green + canary workflow (ADR-0028) — **parked by the archive pivot** (hosted/multi-instance; not the personal run path). The personal release path is the self-contained executable build + smoke in `.github/workflows/release-desktop.yml`. ⚠️ **Caveat:** the implementing workflow `.github/workflows/cd-staging-gate.yml` still auto-triggers on `release: published` and then waits on a `production` environment that doesn't exist for the personal build — so the optional `v0.1.0` archival release would hang it. Disabling/gating that trigger is tracked in **#1228** (a workflow change, out of this docs PR's scope). +- `DEPLOYMENT_WORKFLOW.md` — Staged blue/green + canary workflow (ADR-0028) — **parked by the archive pivot** (hosted/multi-instance; not the personal run path). The beta release path is the self-contained executable build + smoke in `.github/workflows/release-desktop.yml`. The implementing `.github/workflows/cd-staging-gate.yml` is manual-dispatch-only after **#1228**; release publication no longer invokes its parked `production` environment gate. - `RELEASE_CHECKLIST.md` — Smoke checklist for the OPS-09 staged (blue/green/canary) deployment — **parked by the archive pivot** (hosted/multi-instance; requires staging/prod URLs, container images, rollback slots — not the personal run path). - `DEPLOYMENT_CONTAINERS.md` — Container baseline (Dockerfiles, compose, nginx) - `DEPLOYMENT_HARDENING_MATRIX.md` — Container hardening verification matrix diff --git a/docs/strategy/02_PACKAGING_DISTRIBUTION_STRATEGY.md b/docs/strategy/02_PACKAGING_DISTRIBUTION_STRATEGY.md index ebfd4a645..af54b3e8c 100644 --- a/docs/strategy/02_PACKAGING_DISTRIBUTION_STRATEGY.md +++ b/docs/strategy/02_PACKAGING_DISTRIBUTION_STRATEGY.md @@ -4,7 +4,7 @@ **Scope:** Single-executable packaging, installer creation, cross-platform distribution, and first-run experience **Status:** PARTIALLY SUPERSEDED (2026-06-13 archive pivot) — installer / cross-platform distribution / cloud / mobile de-scoped; only the **single self-contained executable + first-run path** below remains the canonical *personal* run goal. -> **⚠️ PARTIALLY SUPERSEDED — 2026-06-13 archive pivot.** This document predates the maintainer's decision to finish Taskdeck for personal use and then archive it. The **installer-creation, cross-platform-distribution, cloud, mobile, and GTM** tracks it describes are **permanently de-scoped** and are retained here only as a historical record of parked plans. **Still active, however:** only the **local build of the single self-contained executable** (`dotnet publish --self-contained … -p:PublishSingleFile=true`) + its first-run experience remains the canonical *personal* run path (see `README.md` and `OUTSTANDING_TASKS.md`). **The *multi-channel distribution* steps in the section below — winget/Homebrew/Snap/Flathub, public download/landing pages, and the "download and run" marketing flow — are the parked distribution framing; do NOT action them.** Nuance on the GitHub Release: cutting a `v0.1.0` tag **auto-fires `release-desktop.yml`**, which builds the self-contained exes and publishes a GitHub Release with SHA256 checksums — that single archival Release **is the retained *optional archival* mechanism** (per `OUTSTANDING_TASKS.md`), not de-scoped; it is just not a distribution *roadmap* (no stores, no marketing). (Heads-up: the same `release: published` event also auto-triggers the parked `cd-staging-gate.yml`, which would hang — tracked in `#1228`.) Current scope: finish + activate the Paper UI (canonical per ADR-0038), make local one-command run trivial (incl. the self-contained exe), general quality, then archive. See `docs/STATUS.md` and the Direction section of `docs/IMPLEMENTATION_MASTERPLAN.md`. +> **⚠️ PARTIALLY SUPERSEDED — 2026-06-13 archive pivot.** This document predates the maintainer's decision to finish Taskdeck for personal use and then archive it. The **installer-creation, cross-platform-distribution, cloud, mobile, and GTM** tracks it describes are **permanently de-scoped** and are retained here only as a historical record of parked plans. **Still active, however:** only the **local build of the single self-contained executable** (`dotnet publish --self-contained … -p:PublishSingleFile=true`) + its first-run experience remains the canonical *personal* run path (see `README.md` and `OUTSTANDING_TASKS.md`). **The *multi-channel distribution* steps in the section below — winget/Homebrew/Snap/Flathub, public download/landing pages, and the "download and run" marketing flow — are the parked distribution framing; do NOT action them.** Nuance on the GitHub Release: cutting a `v0.1.0` tag **auto-fires `release-desktop.yml`**, which builds the self-contained exes and publishes a GitHub Release with SHA256 checksums — that single archival Release **is the retained *optional archival* mechanism** (per `OUTSTANDING_TASKS.md`), not de-scoped; it is just not a distribution *roadmap* (no stores, no marketing). The parked `cd-staging-gate.yml` is manual-dispatch-only after `#1228`, so publishing that release no longer starts its unavailable production-environment gate. Current scope: finish + activate the Paper UI (canonical per ADR-0038), make local one-command run trivial (incl. the self-contained exe), general quality, then archive. See `docs/STATUS.md` and the Direction section of `docs/IMPLEMENTATION_MASTERPLAN.md`. --- From af8bbeee2b45d056b252d18dc59dbbbc38cd6a66 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 04:03:08 +0100 Subject: [PATCH 3/7] Address staging gate review findings Signed-off-by: Chris0Jeky --- .github/workflows/cd-staging-gate.yml | 39 +++++++++--------- docs/IMPLEMENTATION_MASTERPLAN.md | 2 +- docs/STATUS.md | 2 +- docs/ops/DEPLOYMENT_WORKFLOW.md | 2 +- .../02_PACKAGING_DISTRIBUTION_STRATEGY.md | 4 +- scripts/check-github-ops-governance.mjs | 40 +++++++++++++++++++ 6 files changed, 63 insertions(+), 26 deletions(-) diff --git a/.github/workflows/cd-staging-gate.yml b/.github/workflows/cd-staging-gate.yml index 8c6dc4e39..bcad3dea2 100644 --- a/.github/workflows/cd-staging-gate.yml +++ b/.github/workflows/cd-staging-gate.yml @@ -1,7 +1,8 @@ # ============================================================================= -# CD Staging Gate — Automated staging deployment verification and production -# promotion gate. Implements Phase 1-2 of the staged deployment workflow -# (docs/ops/DEPLOYMENT_WORKFLOW.md) with a manual approval gate for Phase 3. +# CD Staging Gate — Automated staging deployment verification with a parked +# production-environment handoff. Implements Phase 1-2 of the staged deployment +# workflow (docs/ops/DEPLOYMENT_WORKFLOW.md). Environment protection is external +# repository state and must not be inferred from this workflow definition. # # Triggers: # - Manual workflow dispatch (for re-runs or pre-release validation) @@ -54,18 +55,13 @@ jobs: id: resolve-tag env: INPUT_TAG: ${{ inputs.image_tag }} - EVENT_NAME: ${{ github.event_name }} - RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - if [[ -n "$INPUT_TAG" ]]; then - TAG="$INPUT_TAG" - elif [[ "$EVENT_NAME" == "release" ]]; then - TAG="$RELEASE_TAG" - else - TAG="$(git describe --tags --always)" + if [[ ! "$INPUT_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + echo "::error::image_tag must be a valid container tag (1-128 letters, digits, underscores, periods, or hyphens)." + exit 1 fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Resolved image tag: $TAG" + printf 'tag=%s\n' "$INPUT_TAG" >> "$GITHUB_OUTPUT" + echo "Resolved image tag: $INPUT_TAG" - name: Setup .NET uses: actions/setup-dotnet@v6 @@ -185,25 +181,26 @@ jobs: EOF # ----------------------------------------------------------------------- - # Production Promotion Gate (manual approval) + # Production Environment Handoff (protection rules are external repository state) # ----------------------------------------------------------------------- promotion-gate: - name: "Phase 3: Production Promotion Gate" + name: "Phase 3: Production Environment Handoff" needs: [build-verification, staging-smoke] if: always() && needs.build-verification.result == 'success' && (needs.staging-smoke.result == 'success' || needs.staging-smoke.result == 'skipped') runs-on: ubuntu-latest environment: production steps: - - name: Promotion approved + - name: Record production environment handoff run: | cat <> "$GITHUB_STEP_SUMMARY" - ## Phase 3: Production Promotion Gate -- APPROVED + ## Phase 3: Production Environment Handoff -- REACHED - **Image tag**: \`${{ needs.build-verification.outputs.image_tag }}\` - Build verification: passed - Staging smoke: ${{ needs.staging-smoke.result }} - - Manual approval: granted - - **Next step**: Execute Phase 3 (canary deployment) and Phase 4 (promotion) per \`docs/ops/DEPLOYMENT_WORKFLOW.md\` + - Environment binding: \`production\` + - Approval protection: external repository setting; not asserted by this workflow + - **Next step**: Keep the parked deployment path inactive until #1504 is maintainer-resolved EOF - echo "Production promotion approved for tag: ${{ needs.build-verification.outputs.image_tag }}" - echo "Follow the deployment workflow in docs/ops/DEPLOYMENT_WORKFLOW.md for Phases 3-4." + echo "Production environment handoff reached for tag: ${{ needs.build-verification.outputs.image_tag }}" + echo "This workflow does not assert that environment approval protection is configured." diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 43c799816..cd0d26969 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -12,7 +12,7 @@ Companion Active Docs: ## Delivery update (2026-07-27, release workflow truth) -- **Manual-only parked staging gate (`#1228`):** `.github/workflows/cd-staging-gate.yml` no longer subscribes to `release: published`; its required-input `workflow_dispatch` path, jobs, permissions, and manual `production` environment approval remain available for deliberate operator runs. Active beta release lanes (`release-desktop.yml`, `ci-release.yml`, and `release-security.yml`) are unchanged, so publishing a release cannot accidentally strand a staging run on a nonexistent environment. +- **Manual-only parked staging gate (`#1228`):** `.github/workflows/cd-staging-gate.yml` no longer subscribes to `release: published`; its required-input `workflow_dispatch` path, jobs, permissions, and `production` environment binding remain available for deliberate operator runs. The repository does not currently configure that environment or required reviewers, so the workflow no longer calls the binding an approval gate; maintainer-owned activation protection is tracked in `#1504`. Active beta release lanes (`release-desktop.yml`, `ci-release.yml`, and `release-security.yml`) are unchanged, so publishing a release cannot start this parked staging path. ## Delivery update (2026-07-26, agentic governance) diff --git a/docs/STATUS.md b/docs/STATUS.md index bbd43cefa..fa7606e0c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -582,7 +582,7 @@ Eight parallel worktree agents delivered new features, security infrastructure, - **CLD-03 OAuth PKCE and account linking** (`#676`/`#812`): DB-backed auth code store replacing in-memory `ConcurrentDictionary` — `OAuthAuthCode` entity with EF migration, `IOAuthAuthCodeRepository` with atomic `TryConsumeAtomicAsync` (raw SQL `UPDATE WHERE IsConsumed = 0 AND ExpiresAt > now`); PKCE support via `UsePkce = true` in ASP.NET Core 8 OAuth middleware; account linking endpoints (`POST /api/auth/github/link`, `DELETE /api/auth/github/link`, `GET /api/auth/linked-accounts`) with conflict detection and session verification; frontend Linked Accounts section in `ProfileSettingsView` with Link/Unlink buttons and avatar display; 24+ backend tests; adversarial review fixed CSRF on account linking, TOCTOU in expiry check, JWT plaintext in DB, DoS via full-table load, and unbounded table growth **Ops & Observability:** -- **OPS-09 Staged deployment workflow** (`#101`/`#806`): ADR-0028 documents blue/green + canary deployment strategy with rollback criteria; `docs/ops/DEPLOYMENT_WORKFLOW.md` canonical 4-phase workflow (build verification → staging → production canary → production promotion) with rollback procedures, database migration safety, emergency hotfix override, and ownership/escalation model; `docs/ops/RELEASE_CHECKLIST.md` versioned smoke verification (7 pre-deploy + 9 automated staging + 7 manual staging + 7 canary + 6 post-promotion + 5 post-release checks) with failure response matrix; `scripts/deploy/smoke-test.sh` portable smoke test (9 automated checks: health, API, auth, board auth gate, frontend, SignalR, static assets, security headers, container restart detection); `.github/workflows/cd-staging-gate.yml` with `production` environment manual approval gate; adversarial review fixed script injection in CI workflow and unscoped container checks. _(⚠️ Parked by the 2026-06-13 archive pivot — staged cloud rollout de-scoped, see the parked-cloud note below. The workflow is manual-dispatch-only after **#1228**, so publishing a beta release no longer invokes the nonexistent `production` environment gate.)_ +- **OPS-09 Staged deployment workflow** (`#101`/`#806`): ADR-0028 documents blue/green + canary deployment strategy with rollback criteria; `docs/ops/DEPLOYMENT_WORKFLOW.md` canonical 4-phase workflow (build verification → staging → production canary → production promotion) with rollback procedures, database migration safety, emergency hotfix override, and ownership/escalation model; `docs/ops/RELEASE_CHECKLIST.md` versioned smoke verification (7 pre-deploy + 9 automated staging + 7 manual staging + 7 canary + 6 post-promotion + 5 post-release checks) with failure response matrix; `scripts/deploy/smoke-test.sh` portable smoke test (9 automated checks: health, API, auth, board auth gate, frontend, SignalR, static assets, security headers, container restart detection); `.github/workflows/cd-staging-gate.yml` retains a `production` environment binding but no live environment protection is configured. _(⚠️ Parked by the 2026-06-13 archive pivot — staged cloud rollout de-scoped, see the parked-cloud note below. The workflow is manual-dispatch-only after **#1228**, so publishing a beta release cannot invoke it; required-reviewer protection before any reactivation is maintainer-owned in **#1504**.)_ - **OBS-02 Error tracking and product analytics** (`#549`/`#811`): config-gated Sentry SDK for backend (`Sentry.AspNetCore` with `BeforeSend` PII scrubbing for emails/JWTs, `ServerName` blanked) and frontend; opt-in product telemetry service (`TelemetryEventService`) aligned with `docs/product/TELEMETRY_TAXONOMY.md` — property key allowlist (15 safe keys), max 10 properties, 200-char value truncation; `TelemetryController` with anonymous config endpoint and authenticated events endpoint; Plausible/Umami analytics script injection (`useAnalyticsScript`) with HTTPS-only URL validation; Pinia `telemetryStore` with consent management, event buffering, and flush; DNT/GPC privacy signal detection prevents auto-restore of consent; telemetry consent toggle in `ProfileSettingsView`; `docs/ops/OBSERVABILITY_SETUP.md` configuration guide; all telemetry opt-in and disabled by default; 38 backend + 25 frontend tests; adversarial review fixed Sentry PII leak, arbitrary properties injection, XSS via script URL, and DNT non-compliance ## Post-Merge Housekeeping (2026-04-12) diff --git a/docs/ops/DEPLOYMENT_WORKFLOW.md b/docs/ops/DEPLOYMENT_WORKFLOW.md index 97e0a3c17..9784f6bb2 100644 --- a/docs/ops/DEPLOYMENT_WORKFLOW.md +++ b/docs/ops/DEPLOYMENT_WORKFLOW.md @@ -295,7 +295,7 @@ The `cd-staging-gate.yml` workflow automates Phase 1 and Phase 2 gates: - Triggers only on manual dispatch with an explicit image tag - Builds and verifies container images - Runs the smoke test suite against a CI-hosted staging environment -- Requires manual approval (GitHub environment protection) before Phase 3 can proceed +- Binds the final handoff job to `production`, but does **not** itself prove approval protection; the live repository has no protected `production` environment, and #1504 must be maintainer-resolved before reactivation - See `.github/workflows/cd-staging-gate.yml` for the workflow definition ## Ownership and Escalation diff --git a/docs/strategy/02_PACKAGING_DISTRIBUTION_STRATEGY.md b/docs/strategy/02_PACKAGING_DISTRIBUTION_STRATEGY.md index af54b3e8c..b3192841d 100644 --- a/docs/strategy/02_PACKAGING_DISTRIBUTION_STRATEGY.md +++ b/docs/strategy/02_PACKAGING_DISTRIBUTION_STRATEGY.md @@ -2,9 +2,9 @@ **Date:** 2026-03-29 **Scope:** Single-executable packaging, installer creation, cross-platform distribution, and first-run experience -**Status:** PARTIALLY SUPERSEDED (2026-06-13 archive pivot) — installer / cross-platform distribution / cloud / mobile de-scoped; only the **single self-contained executable + first-run path** below remains the canonical *personal* run goal. +**Status:** HISTORICAL / PARTIALLY SUPERSEDED — the 2026-06-13 archive pivot was itself superseded by the accepted 2026-07-10 revival (ADR-0044). The canonical delivery plan is `docs/REVIVAL_PLAN.md`; this page retains the older packaging analysis without reactivating its stores, hosted-cloud, mobile, or marketing tracks. -> **⚠️ PARTIALLY SUPERSEDED — 2026-06-13 archive pivot.** This document predates the maintainer's decision to finish Taskdeck for personal use and then archive it. The **installer-creation, cross-platform-distribution, cloud, mobile, and GTM** tracks it describes are **permanently de-scoped** and are retained here only as a historical record of parked plans. **Still active, however:** only the **local build of the single self-contained executable** (`dotnet publish --self-contained … -p:PublishSingleFile=true`) + its first-run experience remains the canonical *personal* run path (see `README.md` and `OUTSTANDING_TASKS.md`). **The *multi-channel distribution* steps in the section below — winget/Homebrew/Snap/Flathub, public download/landing pages, and the "download and run" marketing flow — are the parked distribution framing; do NOT action them.** Nuance on the GitHub Release: cutting a `v0.1.0` tag **auto-fires `release-desktop.yml`**, which builds the self-contained exes and publishes a GitHub Release with SHA256 checksums — that single archival Release **is the retained *optional archival* mechanism** (per `OUTSTANDING_TASKS.md`), not de-scoped; it is just not a distribution *roadmap* (no stores, no marketing). The parked `cd-staging-gate.yml` is manual-dispatch-only after `#1228`, so publishing that release no longer starts its unavailable production-environment gate. Current scope: finish + activate the Paper UI (canonical per ADR-0038), make local one-command run trivial (incl. the self-contained exe), general quality, then archive. See `docs/STATUS.md` and the Direction section of `docs/IMPLEMENTATION_MASTERPLAN.md`. +> **⚠️ HISTORICAL SCOPE.** This document predates both the archive pivot and the current revival. ADR-0044 now admits a free open-beta `v0.1.0` release with the self-contained desktop artifacts and checksums produced by `release-desktop.yml`; that release is a required revival ship gate, not an optional archival gesture. The multi-channel store, hosted-cloud, mobile, and GTM material below remains parked unless a later ratified plan explicitly re-admits it. The parked `cd-staging-gate.yml` is manual-dispatch-only after `#1228`, so publishing the beta release cannot start that path; its unconfigured `production` environment protection remains maintainer-owned in `#1504`. See `docs/STATUS.md`, `docs/REVIVAL_PLAN.md`, and the Direction section of `docs/IMPLEMENTATION_MASTERPLAN.md` for current scope. --- diff --git a/scripts/check-github-ops-governance.mjs b/scripts/check-github-ops-governance.mjs index b996bb91c..79dc8cef9 100644 --- a/scripts/check-github-ops-governance.mjs +++ b/scripts/check-github-ops-governance.mjs @@ -140,10 +140,50 @@ async function validateProjectAutomationDocs() { } } +async function validateParkedStagingGateTriggers() { + const workflowPath = '.github/workflows/cd-staging-gate.yml' + if (!(await fileExists(workflowPath))) { + errors.push(`Missing parked staging workflow: ${workflowPath}`) + return + } + + const workflowText = await readFile(resolve(workflowPath), 'utf8') + const lines = workflowText.split(/\r?\n/) + const onIndex = lines.findIndex((line) => line === 'on:') + if (onIndex < 0) { + errors.push(`${workflowPath} is missing its top-level on block`) + return + } + + const triggerLines = [] + for (let index = onIndex + 1; index < lines.length; index += 1) { + const line = lines[index] + if (line.length > 0 && !/^\s/.test(line)) { + break + } + triggerLines.push(line) + } + + const triggerNames = triggerLines + .map((line) => line.match(/^ {2}([A-Za-z][\w-]*):\s*$/)?.[1]) + .filter(Boolean) + + if (triggerNames.length !== 1 || triggerNames[0] !== 'workflow_dispatch') { + errors.push( + `${workflowPath} is parked and must remain manual-only; expected only workflow_dispatch, found: ${triggerNames.join(', ') || '(none)'}`, + ) + } + + if (workflowText.includes('github.event.release') || workflowText.includes('EVENT_NAME == "release"')) { + errors.push(`${workflowPath} retains unreachable release-event handling after becoming manual-only`) + } +} + async function main() { await validateIssueTemplates() await validateIssueTemplateConfig() await validateProjectAutomationDocs() + await validateParkedStagingGateTriggers() if (errors.length > 0) { console.error('GitHub operations governance check failed:') From 239625530e695a74752598560817b344231fe257 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 04:20:17 +0100 Subject: [PATCH 4/7] Harden staging gate governance checks Signed-off-by: Chris0Jeky --- .../workflows/reusable-docs-governance.yml | 4 +- OUTSTANDING_TASKS.md | 1 + scripts/check-github-ops-governance.mjs | 186 +++++++++++++++--- scripts/check-github-ops-governance.test.mjs | 123 ++++++++++++ 4 files changed, 289 insertions(+), 25 deletions(-) create mode 100644 scripts/check-github-ops-governance.test.mjs diff --git a/.github/workflows/reusable-docs-governance.yml b/.github/workflows/reusable-docs-governance.yml index ea1650e77..358f2e65c 100644 --- a/.github/workflows/reusable-docs-governance.yml +++ b/.github/workflows/reusable-docs-governance.yml @@ -58,4 +58,6 @@ jobs: run: node scripts/check-golden-principles.mjs - name: Validate GitHub operations governance invariants - run: node scripts/check-github-ops-governance.mjs + run: | + node --test scripts/check-github-ops-governance.test.mjs + node scripts/check-github-ops-governance.mjs diff --git a/OUTSTANDING_TASKS.md b/OUTSTANDING_TASKS.md index 45428339d..887c78b53 100644 --- a/OUTSTANDING_TASKS.md +++ b/OUTSTANDING_TASKS.md @@ -88,6 +88,7 @@ Analysis docs: `docs/PROJECT_TRAJECTORY.md` (strengths + path) and `docs/COURSE_ ## E. Revival overnight deferrals (added 2026-07-13) +- [ ] **#1504 — protect the production environment before staging-gate reactivation.** Configure required reviewers in repository settings, prove the protection with a manual rehearsal, and only then reconsider automatic promotion. This is maintainer-only; the parked workflow does not assert that external protection exists. - [ ] **#1173** — Apply and verify the required branch-protection settings in GitHub. This is repository-settings work and remains maintainer-only. - [ ] **#1303** — Push the `v0.1.0` release tag only after the maintainer accepts the release deck and its pipeline evidence. Agents may repair the pipeline but must not push the tag. - [ ] **#1482 — Taskdeck name, pre-commercial legal residuals.** *(Supersedes the old "#1299 trademark check" line: #1299 shipped with PR #1337 on 2026-07-25 and its AC4 search + cost + recommendation are recorded on the issue.)* What remains is genuinely legal and none of it blocks the free beta: an **attorney registrability opinion** for Classes 9/42 asking about *descriptiveness* (not just conflict), a read on **TASKADE** (live, incontestable US Cl. 42 mark covering task management), the UKIPO/EUIPO coverage gaps (UKIPO hard-blocked automated access, so all GB data is indirect), perishable namespace reservations, and the **keep-or-rename decision before `v0.1.0` (#1303)** — renaming is nearly free pre-tag and expensive after. diff --git a/scripts/check-github-ops-governance.mjs b/scripts/check-github-ops-governance.mjs index 79dc8cef9..7609cff4d 100644 --- a/scripts/check-github-ops-governance.mjs +++ b/scripts/check-github-ops-governance.mjs @@ -3,6 +3,7 @@ import { access, readFile } from 'node:fs/promises' import { constants as fsConstants } from 'node:fs' import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' const errors = [] @@ -140,43 +141,178 @@ async function validateProjectAutomationDocs() { } } -async function validateParkedStagingGateTriggers() { - const workflowPath = '.github/workflows/cd-staging-gate.yml' - if (!(await fileExists(workflowPath))) { - errors.push(`Missing parked staging workflow: ${workflowPath}`) - return - } - - const workflowText = await readFile(resolve(workflowPath), 'utf8') +export function inspectParkedStagingGateTriggers(workflowText) { const lines = workflowText.split(/\r?\n/) const onIndex = lines.findIndex((line) => line === 'on:') if (onIndex < 0) { - errors.push(`${workflowPath} is missing its top-level on block`) - return + return { onBlockFound: false, triggerNames: [], unsupportedEntries: [] } } - const triggerLines = [] + const triggerNames = [] + const unsupportedEntries = [] for (let index = onIndex + 1; index < lines.length; index += 1) { const line = lines[index] if (line.length > 0 && !/^\s/.test(line)) { break } - triggerLines.push(line) + + // This governance contract deliberately requires two-space event keys. Any + // unfamiliar entry at that level fails closed instead of being ignored. + const eventEntry = line.match(/^ {2}(?!\s)(.*)$/)?.[1] + if (!eventEntry || eventEntry.startsWith('#')) { + continue + } + + const eventMatch = eventEntry.match( + /^(?:"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z][\w-]*))\s*:(?:\s*.*)?$/, + ) + if (!eventMatch) { + unsupportedEntries.push(eventEntry) + continue + } + + triggerNames.push(eventMatch[1] ?? eventMatch[2] ?? eventMatch[3]) + } + + return { onBlockFound: true, triggerNames, unsupportedEntries } +} + +export function retainsReleaseEventHandling(workflowText) { + return /github\.event(?:_name|\.release)\b/.test(workflowText) || /\bEVENT_NAME\b/.test(workflowText) +} + +function parseMappingEntryAtIndent(line, indent) { + const content = line.match(new RegExp(`^ {${indent}}(?!\\s)(.*)$`))?.[1] + if (!content || content.startsWith('#')) { + return null + } + + const match = content.match( + /^(?:"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z][\w-]*))\s*:\s*(.*)$/, + ) + if (!match) { + return null + } + + return { key: match[1] ?? match[2] ?? match[3], value: match[4] } +} + +function findDirectChild(lines, parentIndex, parentIndent, childIndent, key) { + for (let index = parentIndex + 1; index < lines.length; index += 1) { + const line = lines[index] + const trimmed = line.trim() + if (trimmed.length === 0 || trimmed.startsWith('#')) { + continue + } + + const indentation = line.match(/^ */)?.[0].length ?? 0 + if (indentation <= parentIndent) { + break + } + + const entry = parseMappingEntryAtIndent(line, childIndent) + if (entry?.key === key) { + return { index, entry } + } + } + + return null +} + +function normalizeYamlScalar(value) { + const withoutComment = value.replace(/\s+#.*$/, '').trim() + const quoted = withoutComment.match(/^(?:"([^"]*)"|'([^']*)')$/) + return quoted ? (quoted[1] ?? quoted[2]) : withoutComment +} + +export function inspectWorkflowDispatchImageTagInput(workflowText) { + const lines = workflowText.split(/\r?\n/) + const onIndex = lines.findIndex((line) => line === 'on:') + if (onIndex < 0) { + return { imageTagFound: false, requiredValues: [], typeValues: [] } + } + + const workflowDispatch = findDirectChild(lines, onIndex, 0, 2, 'workflow_dispatch') + const inputs = workflowDispatch + ? findDirectChild(lines, workflowDispatch.index, 2, 4, 'inputs') + : null + const imageTag = inputs ? findDirectChild(lines, inputs.index, 4, 6, 'image_tag') : null + if (!imageTag) { + return { imageTagFound: false, requiredValues: [], typeValues: [] } + } + + const requiredValues = [] + const typeValues = [] + for (let index = imageTag.index + 1; index < lines.length; index += 1) { + const line = lines[index] + const trimmed = line.trim() + if (trimmed.length === 0 || trimmed.startsWith('#')) { + continue + } + + const indentation = line.match(/^ */)?.[0].length ?? 0 + if (indentation <= 6) { + break + } + + const entry = parseMappingEntryAtIndent(line, 8) + if (entry?.key === 'required') { + requiredValues.push(normalizeYamlScalar(entry.value)) + } else if (entry?.key === 'type') { + typeValues.push(normalizeYamlScalar(entry.value)) + } } - const triggerNames = triggerLines - .map((line) => line.match(/^ {2}([A-Za-z][\w-]*):\s*$/)?.[1]) - .filter(Boolean) + return { imageTagFound: true, requiredValues, typeValues } +} - if (triggerNames.length !== 1 || triggerNames[0] !== 'workflow_dispatch') { - errors.push( - `${workflowPath} is parked and must remain manual-only; expected only workflow_dispatch, found: ${triggerNames.join(', ') || '(none)'}`, +export function validateParkedStagingGateWorkflow(workflowText, workflowPath = '.github/workflows/cd-staging-gate.yml') { + const workflowErrors = [] + const inspection = inspectParkedStagingGateTriggers(workflowText) + if (!inspection.onBlockFound) { + return [`${workflowPath} is missing its top-level on block`] + } + + if (inspection.unsupportedEntries.length > 0) { + workflowErrors.push( + `${workflowPath} has unsupported top-level trigger entries: ${inspection.unsupportedEntries.join(', ')}`, ) } - if (workflowText.includes('github.event.release') || workflowText.includes('EVENT_NAME == "release"')) { - errors.push(`${workflowPath} retains unreachable release-event handling after becoming manual-only`) + if (inspection.triggerNames.length !== 1 || inspection.triggerNames[0] !== 'workflow_dispatch') { + workflowErrors.push( + `${workflowPath} is parked and must remain manual-only; expected only workflow_dispatch, found: ${inspection.triggerNames.join(', ') || '(none)'}`, + ) } + + const imageTagInput = inspectWorkflowDispatchImageTagInput(workflowText) + if (!imageTagInput.imageTagFound) { + workflowErrors.push(`${workflowPath} must define workflow_dispatch.inputs.image_tag`) + } else { + if (imageTagInput.requiredValues.length !== 1 || imageTagInput.requiredValues[0] !== 'true') { + workflowErrors.push(`${workflowPath} workflow_dispatch.inputs.image_tag must set required: true exactly once`) + } + if (imageTagInput.typeValues.length !== 1 || imageTagInput.typeValues[0] !== 'string') { + workflowErrors.push(`${workflowPath} workflow_dispatch.inputs.image_tag must set type: string exactly once`) + } + } + + if (retainsReleaseEventHandling(workflowText)) { + workflowErrors.push(`${workflowPath} retains unreachable release-event handling after becoming manual-only`) + } + + return workflowErrors +} + +async function validateParkedStagingGateTriggers() { + const workflowPath = '.github/workflows/cd-staging-gate.yml' + if (!(await fileExists(workflowPath))) { + errors.push(`Missing parked staging workflow: ${workflowPath}`) + return + } + + const workflowText = await readFile(resolve(workflowPath), 'utf8') + errors.push(...validateParkedStagingGateWorkflow(workflowText, workflowPath)) } async function main() { @@ -196,7 +332,9 @@ async function main() { console.log('GitHub operations governance check passed.') } -main().catch((error) => { - console.error('GitHub operations governance check crashed:', error) - process.exit(1) -}) +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error('GitHub operations governance check crashed:', error) + process.exit(1) + }) +} diff --git a/scripts/check-github-ops-governance.test.mjs b/scripts/check-github-ops-governance.test.mjs new file mode 100644 index 000000000..e32d55c84 --- /dev/null +++ b/scripts/check-github-ops-governance.test.mjs @@ -0,0 +1,123 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + inspectParkedStagingGateTriggers, + inspectWorkflowDispatchImageTagInput, + retainsReleaseEventHandling, + validateParkedStagingGateWorkflow, +} from './check-github-ops-governance.mjs' + +const canonicalWorkflow = `name: gate +on: + workflow_dispatch: + inputs: + image_tag: + description: image + required: true + type: string +jobs: {} +` + +test('accepts the canonical workflow_dispatch mapping and ignores nested inputs', () => { + const result = inspectParkedStagingGateTriggers(canonicalWorkflow) + + assert.deepEqual(result, { + onBlockFound: true, + triggerNames: ['workflow_dispatch'], + unsupportedEntries: [], + }) + assert.deepEqual(inspectWorkflowDispatchImageTagInput(canonicalWorkflow), { + imageTagFound: true, + requiredValues: ['true'], + typeValues: ['string'], + }) + assert.deepEqual(validateParkedStagingGateWorkflow(canonicalWorkflow), []) +}) + +test('discovers an inline flow-style release trigger', () => { + const workflow = `on: + workflow_dispatch: + inputs: + image_tag: + required: true + type: string + release: { types: [published] } +jobs: {} +` + const result = inspectParkedStagingGateTriggers(workflow) + + assert.deepEqual(result.triggerNames, ['workflow_dispatch', 'release']) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /must remain manual-only/) +}) + +test('discovers a quoted release trigger', () => { + const result = inspectParkedStagingGateTriggers(`on: + workflow_dispatch: + "release": { types: [published] } +jobs: {} +`) + + assert.deepEqual(result.triggerNames, ['workflow_dispatch', 'release']) +}) + +test('discovers a comment-suffixed release trigger', () => { + const result = inspectParkedStagingGateTriggers(`on: + workflow_dispatch: + release: # publish events + types: [published] +jobs: {} +`) + + assert.deepEqual(result.triggerNames, ['workflow_dispatch', 'release']) +}) + +test('fails closed on an unfamiliar indentation-two trigger entry', () => { + const result = inspectParkedStagingGateTriggers(`on: + workflow_dispatch: + ? release +jobs: {} +`) + + assert.deepEqual(result.triggerNames, ['workflow_dispatch']) + assert.deepEqual(result.unsupportedEntries, ['? release']) +}) + +test('recognizes stale release-event branches despite shell quoting variations', () => { + assert.equal(retainsReleaseEventHandling('if [[ "$EVENT_NAME" == "release" ]]; then'), true) + assert.equal(retainsReleaseEventHandling("if: github.event_name == 'release'"), true) + assert.equal(retainsReleaseEventHandling('tag: ${{ github.event.release.tag_name }}'), true) + assert.equal(retainsReleaseEventHandling('tag: ${{ inputs.image_tag }}'), false) +}) + +test('detects a non-required image_tag input', () => { + const workflow = `on: + workflow_dispatch: + inputs: + image_tag: + required: false + type: string +jobs: {} +` + const result = inspectWorkflowDispatchImageTagInput(workflow) + + assert.deepEqual(result.requiredValues, ['false']) + assert.deepEqual(result.typeValues, ['string']) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /required: true/) +}) + +test('detects a non-string image_tag input', () => { + const workflow = `on: + workflow_dispatch: + inputs: + image_tag: + required: true + type: boolean +jobs: {} +` + const result = inspectWorkflowDispatchImageTagInput(workflow) + + assert.deepEqual(result.requiredValues, ['true']) + assert.deepEqual(result.typeValues, ['boolean']) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /type: string/) +}) From b5c3c1c593a32fedbab45c1898638632b0612395 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 04:44:24 +0100 Subject: [PATCH 5/7] Close staging gate governance bypasses Signed-off-by: Chris0Jeky --- docs/TESTING_GUIDE.md | 4 +- scripts/check-github-ops-governance.mjs | 173 ++++--------------- scripts/check-github-ops-governance.test.mjs | 122 ++++++++----- 3 files changed, 114 insertions(+), 185 deletions(-) diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index f1e111d22..bda12a80e 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -2,7 +2,7 @@ This is the active testing guide for Taskdeck. -Last Updated: 2026-07-26 +Last Updated: 2026-07-27 Companion Active Docs: - `docs/STATUS.md` - `docs/IMPLEMENTATION_MASTERPLAN.md` @@ -83,6 +83,7 @@ py -3 -B scripts/agent_hooks/render_failure_ledger.py; if ($LASTEXITCODE -ne 0) py -3 -B -m unittest discover -s scripts/agent_hooks -p "test_render_failure_ledger.py"; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node scripts\check-docs-governance.mjs; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node scripts\check-golden-principles.mjs; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +node --test scripts\check-github-ops-governance.test.mjs; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node scripts\check-github-ops-governance.mjs; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ``` @@ -103,6 +104,7 @@ python3 -B scripts/agent_hooks/render_failure_ledger.py python3 -B -m unittest discover -s scripts/agent_hooks -p 'test_render_failure_ledger.py' node scripts/check-docs-governance.mjs node scripts/check-golden-principles.mjs +node --test scripts/check-github-ops-governance.test.mjs node scripts/check-github-ops-governance.mjs ``` diff --git a/scripts/check-github-ops-governance.mjs b/scripts/check-github-ops-governance.mjs index 7609cff4d..e115548e1 100644 --- a/scripts/check-github-ops-governance.mjs +++ b/scripts/check-github-ops-governance.mjs @@ -7,6 +7,21 @@ import { fileURLToPath } from 'node:url' const errors = [] +const expectedParkedStagingGateOnBlock = [ + 'on:', + ' workflow_dispatch:', + ' inputs:', + ' image_tag:', + ' description: "Container image tag to deploy (e.g., v0.2.0)"', + ' required: true', + ' type: string', + ' skip_smoke:', + ' description: "Skip smoke tests (emergency only)"', + ' required: false', + ' type: boolean', + ' default: false', +].join('\n') + const requiredIssueTemplateFiles = [ '.github/ISSUE_TEMPLATE/bug_report.md', '.github/ISSUE_TEMPLATE/feature.md', @@ -141,162 +156,38 @@ async function validateProjectAutomationDocs() { } } -export function inspectParkedStagingGateTriggers(workflowText) { - const lines = workflowText.split(/\r?\n/) - const onIndex = lines.findIndex((line) => line === 'on:') - if (onIndex < 0) { - return { onBlockFound: false, triggerNames: [], unsupportedEntries: [] } - } - - const triggerNames = [] - const unsupportedEntries = [] - for (let index = onIndex + 1; index < lines.length; index += 1) { - const line = lines[index] - if (line.length > 0 && !/^\s/.test(line)) { - break - } - - // This governance contract deliberately requires two-space event keys. Any - // unfamiliar entry at that level fails closed instead of being ignored. - const eventEntry = line.match(/^ {2}(?!\s)(.*)$/)?.[1] - if (!eventEntry || eventEntry.startsWith('#')) { - continue - } - - const eventMatch = eventEntry.match( - /^(?:"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z][\w-]*))\s*:(?:\s*.*)?$/, - ) - if (!eventMatch) { - unsupportedEntries.push(eventEntry) - continue - } - - triggerNames.push(eventMatch[1] ?? eventMatch[2] ?? eventMatch[3]) - } - - return { onBlockFound: true, triggerNames, unsupportedEntries } -} - export function retainsReleaseEventHandling(workflowText) { - return /github\.event(?:_name|\.release)\b/.test(workflowText) || /\bEVENT_NAME\b/.test(workflowText) -} - -function parseMappingEntryAtIndent(line, indent) { - const content = line.match(new RegExp(`^ {${indent}}(?!\\s)(.*)$`))?.[1] - if (!content || content.startsWith('#')) { - return null - } - - const match = content.match( - /^(?:"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z][\w-]*))\s*:\s*(.*)$/, - ) - if (!match) { - return null - } - - return { key: match[1] ?? match[2] ?? match[3], value: match[4] } + const normalizedReferences = workflowText.toLowerCase().replace(/[^a-z0-9_]+/g, '.') + return normalizedReferences.includes('event_name') || normalizedReferences.includes('github.event.release') } -function findDirectChild(lines, parentIndex, parentIndent, childIndent, key) { - for (let index = parentIndex + 1; index < lines.length; index += 1) { - const line = lines[index] - const trimmed = line.trim() - if (trimmed.length === 0 || trimmed.startsWith('#')) { - continue - } - - const indentation = line.match(/^ */)?.[0].length ?? 0 - if (indentation <= parentIndent) { - break - } - - const entry = parseMappingEntryAtIndent(line, childIndent) - if (entry?.key === key) { - return { index, entry } - } - } - - return null -} - -function normalizeYamlScalar(value) { - const withoutComment = value.replace(/\s+#.*$/, '').trim() - const quoted = withoutComment.match(/^(?:"([^"]*)"|'([^']*)')$/) - return quoted ? (quoted[1] ?? quoted[2]) : withoutComment -} - -export function inspectWorkflowDispatchImageTagInput(workflowText) { - const lines = workflowText.split(/\r?\n/) - const onIndex = lines.findIndex((line) => line === 'on:') - if (onIndex < 0) { - return { imageTagFound: false, requiredValues: [], typeValues: [] } - } - - const workflowDispatch = findDirectChild(lines, onIndex, 0, 2, 'workflow_dispatch') - const inputs = workflowDispatch - ? findDirectChild(lines, workflowDispatch.index, 2, 4, 'inputs') - : null - const imageTag = inputs ? findDirectChild(lines, inputs.index, 4, 6, 'image_tag') : null - if (!imageTag) { - return { imageTagFound: false, requiredValues: [], typeValues: [] } +export function hasExactParkedStagingGateOnBlock(workflowText) { + const normalized = workflowText.replace(/\r\n/g, '\n') + const onMatches = [...normalized.matchAll(/^on:[ \t]*$/gm)] + if (onMatches.length !== 1) { + return false } - const requiredValues = [] - const typeValues = [] - for (let index = imageTag.index + 1; index < lines.length; index += 1) { - const line = lines[index] - const trimmed = line.trim() - if (trimmed.length === 0 || trimmed.startsWith('#')) { - continue - } - - const indentation = line.match(/^ */)?.[0].length ?? 0 - if (indentation <= 6) { - break - } - - const entry = parseMappingEntryAtIndent(line, 8) - if (entry?.key === 'required') { - requiredValues.push(normalizeYamlScalar(entry.value)) - } else if (entry?.key === 'type') { - typeValues.push(normalizeYamlScalar(entry.value)) - } + const permissionsMatch = [...normalized.matchAll(/^permissions:[ \t]*$/gm)] + .find((match) => match.index > onMatches[0].index) + if (!permissionsMatch) { + return false } - return { imageTagFound: true, requiredValues, typeValues } + const actualBlock = normalized + .slice(onMatches[0].index, permissionsMatch.index) + .trimEnd() + return actualBlock === expectedParkedStagingGateOnBlock } export function validateParkedStagingGateWorkflow(workflowText, workflowPath = '.github/workflows/cd-staging-gate.yml') { const workflowErrors = [] - const inspection = inspectParkedStagingGateTriggers(workflowText) - if (!inspection.onBlockFound) { - return [`${workflowPath} is missing its top-level on block`] - } - - if (inspection.unsupportedEntries.length > 0) { + if (!hasExactParkedStagingGateOnBlock(workflowText)) { workflowErrors.push( - `${workflowPath} has unsupported top-level trigger entries: ${inspection.unsupportedEntries.join(', ')}`, + `${workflowPath} must retain the exact reviewed manual-only workflow_dispatch/input block`, ) } - if (inspection.triggerNames.length !== 1 || inspection.triggerNames[0] !== 'workflow_dispatch') { - workflowErrors.push( - `${workflowPath} is parked and must remain manual-only; expected only workflow_dispatch, found: ${inspection.triggerNames.join(', ') || '(none)'}`, - ) - } - - const imageTagInput = inspectWorkflowDispatchImageTagInput(workflowText) - if (!imageTagInput.imageTagFound) { - workflowErrors.push(`${workflowPath} must define workflow_dispatch.inputs.image_tag`) - } else { - if (imageTagInput.requiredValues.length !== 1 || imageTagInput.requiredValues[0] !== 'true') { - workflowErrors.push(`${workflowPath} workflow_dispatch.inputs.image_tag must set required: true exactly once`) - } - if (imageTagInput.typeValues.length !== 1 || imageTagInput.typeValues[0] !== 'string') { - workflowErrors.push(`${workflowPath} workflow_dispatch.inputs.image_tag must set type: string exactly once`) - } - } - if (retainsReleaseEventHandling(workflowText)) { workflowErrors.push(`${workflowPath} retains unreachable release-event handling after becoming manual-only`) } diff --git a/scripts/check-github-ops-governance.test.mjs b/scripts/check-github-ops-governance.test.mjs index e32d55c84..9d6c9b8f5 100644 --- a/scripts/check-github-ops-governance.test.mjs +++ b/scripts/check-github-ops-governance.test.mjs @@ -2,8 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - inspectParkedStagingGateTriggers, - inspectWorkflowDispatchImageTagInput, + hasExactParkedStagingGateOnBlock, retainsReleaseEventHandling, validateParkedStagingGateWorkflow, } from './check-github-ops-governance.mjs' @@ -13,29 +12,25 @@ on: workflow_dispatch: inputs: image_tag: - description: image + description: "Container image tag to deploy (e.g., v0.2.0)" required: true type: string + skip_smoke: + description: "Skip smoke tests (emergency only)" + required: false + type: boolean + default: false +permissions: + contents: read jobs: {} ` -test('accepts the canonical workflow_dispatch mapping and ignores nested inputs', () => { - const result = inspectParkedStagingGateTriggers(canonicalWorkflow) - - assert.deepEqual(result, { - onBlockFound: true, - triggerNames: ['workflow_dispatch'], - unsupportedEntries: [], - }) - assert.deepEqual(inspectWorkflowDispatchImageTagInput(canonicalWorkflow), { - imageTagFound: true, - requiredValues: ['true'], - typeValues: ['string'], - }) +test('accepts the exact reviewed manual-only workflow_dispatch block', () => { + assert.equal(hasExactParkedStagingGateOnBlock(canonicalWorkflow), true) assert.deepEqual(validateParkedStagingGateWorkflow(canonicalWorkflow), []) }) -test('discovers an inline flow-style release trigger', () => { +test('rejects an inline flow-style release trigger', () => { const workflow = `on: workflow_dispatch: inputs: @@ -45,48 +40,52 @@ test('discovers an inline flow-style release trigger', () => { release: { types: [published] } jobs: {} ` - const result = inspectParkedStagingGateTriggers(workflow) - - assert.deepEqual(result.triggerNames, ['workflow_dispatch', 'release']) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /must remain manual-only/) + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) }) -test('discovers a quoted release trigger', () => { - const result = inspectParkedStagingGateTriggers(`on: +test('rejects a quoted release trigger', () => { + const workflow = `on: workflow_dispatch: "release": { types: [published] } jobs: {} -`) +` - assert.deepEqual(result.triggerNames, ['workflow_dispatch', 'release']) + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) }) -test('discovers a comment-suffixed release trigger', () => { - const result = inspectParkedStagingGateTriggers(`on: +test('rejects a comment-suffixed release trigger', () => { + const workflow = `on: workflow_dispatch: release: # publish events types: [published] jobs: {} -`) +` - assert.deepEqual(result.triggerNames, ['workflow_dispatch', 'release']) + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) }) -test('fails closed on an unfamiliar indentation-two trigger entry', () => { - const result = inspectParkedStagingGateTriggers(`on: +test('rejects an unfamiliar indentation-two trigger entry', () => { + const workflow = `on: workflow_dispatch: ? release jobs: {} -`) +` - assert.deepEqual(result.triggerNames, ['workflow_dispatch']) - assert.deepEqual(result.unsupportedEntries, ['? release']) + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) }) test('recognizes stale release-event branches despite shell quoting variations', () => { assert.equal(retainsReleaseEventHandling('if [[ "$EVENT_NAME" == "release" ]]; then'), true) assert.equal(retainsReleaseEventHandling("if: github.event_name == 'release'"), true) assert.equal(retainsReleaseEventHandling('tag: ${{ github.event.release.tag_name }}'), true) + assert.equal(retainsReleaseEventHandling("if: github['event_name'] == 'release'"), true) + assert.equal(retainsReleaseEventHandling("tag: ${{ github['event']['release']['tag_name'] }}"), true) + assert.equal(retainsReleaseEventHandling('tag: ${{ github.event["release"].tag_name }}'), true) + assert.equal(retainsReleaseEventHandling('if [[ "$GITHUB_EVENT_NAME" == "release" ]]; then'), true) assert.equal(retainsReleaseEventHandling('tag: ${{ inputs.image_tag }}'), false) }) @@ -99,11 +98,8 @@ test('detects a non-required image_tag input', () => { type: string jobs: {} ` - const result = inspectWorkflowDispatchImageTagInput(workflow) - - assert.deepEqual(result.requiredValues, ['false']) - assert.deepEqual(result.typeValues, ['string']) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /required: true/) + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) }) test('detects a non-string image_tag input', () => { @@ -115,9 +111,49 @@ test('detects a non-string image_tag input', () => { type: boolean jobs: {} ` - const result = inspectWorkflowDispatchImageTagInput(workflow) + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) +}) + +test('rejects a defaulted image_tag input', () => { + const workflow = `on: + workflow_dispatch: + inputs: + image_tag: + required: true + type: string + default: latest +jobs: {} +` + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) +}) + +test('rejects an event after a column-zero comment inside the on mapping', () => { + const workflow = canonicalWorkflow.replace( + '\npermissions:', + '\n# A column-zero comment does not end the YAML mapping.\n release:\n types: [published]\npermissions:', + ) + + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) +}) + +test('rejects multiline scalar text that forges apparent image_tag properties', () => { + const workflow = canonicalWorkflow.replace( + ' description: "Container image tag to deploy (e.g., v0.2.0)"\n required: true\n type: string', + ' description: |\n required: true\n type: string', + ) + + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) +}) + +test('rejects escaped YAML property keys that decode to a different input contract', () => { + const workflow = canonicalWorkflow + .replace(' required: true', ' "requ\\u0069red": false') + .replace(' type: string', ' "t\\u0079pe": boolean') - assert.deepEqual(result.requiredValues, ['true']) - assert.deepEqual(result.typeValues, ['boolean']) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /type: string/) + assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) }) From d1e1c20d41cfed916b4a6ec919584b5fe1bd39b6 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 04:59:33 +0100 Subject: [PATCH 6/7] Pin the parked staging workflow Signed-off-by: Chris0Jeky --- docs/TESTING_GUIDE.md | 4 + scripts/check-github-ops-governance.mjs | 39 ++------- scripts/check-github-ops-governance.test.mjs | 86 ++++++++++---------- 3 files changed, 56 insertions(+), 73 deletions(-) diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index bda12a80e..2542d976b 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -108,6 +108,10 @@ node --test scripts/check-github-ops-governance.test.mjs node scripts/check-github-ops-governance.mjs ``` +The staging-gate governance regression pins the complete parked workflow after normalizing line +endings. Any intentional edit to that workflow requires a reviewed digest and fixture update plus +Actionlint; substring checks are not treated as proof of effective YAML semantics. + The native-Windows hook smoke test executes the configured `.claude/settings.json` command handlers with `CLAUDE_PROJECT_DIR` set, including PowerShell-hosted handlers, representative dangerous Bash-command denials, missing-launcher and missing-policy fail-closed probes, failure-ledger redaction, and pre-commit no-op behavior. Its payloads identify the `Bash` tool; it does not prove native PowerShell-tool interception. That T4 policy gap is tracked by [#1497](https://github.com/Chris0Jeky/Taskdeck/issues/1497). When MCP availability itself is part of the change, also run the active runtime's MCP listing/auth command if available. Do not claim remote MCP connectivity unless the current session actually verified it. diff --git a/scripts/check-github-ops-governance.mjs b/scripts/check-github-ops-governance.mjs index e115548e1..9e5f90136 100644 --- a/scripts/check-github-ops-governance.mjs +++ b/scripts/check-github-ops-governance.mjs @@ -2,25 +2,13 @@ import { access, readFile } from 'node:fs/promises' import { constants as fsConstants } from 'node:fs' +import { createHash } from 'node:crypto' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' const errors = [] -const expectedParkedStagingGateOnBlock = [ - 'on:', - ' workflow_dispatch:', - ' inputs:', - ' image_tag:', - ' description: "Container image tag to deploy (e.g., v0.2.0)"', - ' required: true', - ' type: string', - ' skip_smoke:', - ' description: "Skip smoke tests (emergency only)"', - ' required: false', - ' type: boolean', - ' default: false', -].join('\n') +const expectedParkedStagingGateSha256 = '3b38f6eec8cc5e1b5ef486697bb4d746db97a5ce05baa8d96a3663de503dc110' const requiredIssueTemplateFiles = [ '.github/ISSUE_TEMPLATE/bug_report.md', @@ -161,30 +149,17 @@ export function retainsReleaseEventHandling(workflowText) { return normalizedReferences.includes('event_name') || normalizedReferences.includes('github.event.release') } -export function hasExactParkedStagingGateOnBlock(workflowText) { +export function hasExpectedParkedStagingGateWorkflow(workflowText) { const normalized = workflowText.replace(/\r\n/g, '\n') - const onMatches = [...normalized.matchAll(/^on:[ \t]*$/gm)] - if (onMatches.length !== 1) { - return false - } - - const permissionsMatch = [...normalized.matchAll(/^permissions:[ \t]*$/gm)] - .find((match) => match.index > onMatches[0].index) - if (!permissionsMatch) { - return false - } - - const actualBlock = normalized - .slice(onMatches[0].index, permissionsMatch.index) - .trimEnd() - return actualBlock === expectedParkedStagingGateOnBlock + const actualSha256 = createHash('sha256').update(normalized, 'utf8').digest('hex') + return actualSha256 === expectedParkedStagingGateSha256 } export function validateParkedStagingGateWorkflow(workflowText, workflowPath = '.github/workflows/cd-staging-gate.yml') { const workflowErrors = [] - if (!hasExactParkedStagingGateOnBlock(workflowText)) { + if (!hasExpectedParkedStagingGateWorkflow(workflowText)) { workflowErrors.push( - `${workflowPath} must retain the exact reviewed manual-only workflow_dispatch/input block`, + `${workflowPath} must match the complete reviewed parked-workflow digest`, ) } diff --git a/scripts/check-github-ops-governance.test.mjs b/scripts/check-github-ops-governance.test.mjs index 9d6c9b8f5..13e2dc3bf 100644 --- a/scripts/check-github-ops-governance.test.mjs +++ b/scripts/check-github-ops-governance.test.mjs @@ -1,32 +1,24 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { - hasExactParkedStagingGateOnBlock, + hasExpectedParkedStagingGateWorkflow, retainsReleaseEventHandling, validateParkedStagingGateWorkflow, } from './check-github-ops-governance.mjs' -const canonicalWorkflow = `name: gate -on: - workflow_dispatch: - inputs: - image_tag: - description: "Container image tag to deploy (e.g., v0.2.0)" - required: true - type: string - skip_smoke: - description: "Skip smoke tests (emergency only)" - required: false - type: boolean - default: false -permissions: - contents: read -jobs: {} -` +const canonicalWorkflow = readFileSync( + new URL('../.github/workflows/cd-staging-gate.yml', import.meta.url), + 'utf8', +) -test('accepts the exact reviewed manual-only workflow_dispatch block', () => { - assert.equal(hasExactParkedStagingGateOnBlock(canonicalWorkflow), true) +test('accepts the complete reviewed parked workflow', () => { + assert.equal(hasExpectedParkedStagingGateWorkflow(canonicalWorkflow), true) + assert.equal( + hasExpectedParkedStagingGateWorkflow(canonicalWorkflow.replace(/\r?\n/g, '\r\n')), + true, + ) assert.deepEqual(validateParkedStagingGateWorkflow(canonicalWorkflow), []) }) @@ -40,8 +32,8 @@ test('rejects an inline flow-style release trigger', () => { release: { types: [published] } jobs: {} ` - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) test('rejects a quoted release trigger', () => { @@ -51,8 +43,8 @@ test('rejects a quoted release trigger', () => { jobs: {} ` - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) test('rejects a comment-suffixed release trigger', () => { @@ -63,8 +55,8 @@ test('rejects a comment-suffixed release trigger', () => { jobs: {} ` - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) test('rejects an unfamiliar indentation-two trigger entry', () => { @@ -74,8 +66,8 @@ test('rejects an unfamiliar indentation-two trigger entry', () => { jobs: {} ` - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) test('recognizes stale release-event branches despite shell quoting variations', () => { @@ -98,8 +90,8 @@ test('detects a non-required image_tag input', () => { type: string jobs: {} ` - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) test('detects a non-string image_tag input', () => { @@ -111,8 +103,8 @@ test('detects a non-string image_tag input', () => { type: boolean jobs: {} ` - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) test('rejects a defaulted image_tag input', () => { @@ -125,8 +117,8 @@ test('rejects a defaulted image_tag input', () => { default: latest jobs: {} ` - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) test('rejects an event after a column-zero comment inside the on mapping', () => { @@ -135,18 +127,18 @@ test('rejects an event after a column-zero comment inside the on mapping', () => '\n# A column-zero comment does not end the YAML mapping.\n release:\n types: [published]\npermissions:', ) - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) test('rejects multiline scalar text that forges apparent image_tag properties', () => { const workflow = canonicalWorkflow.replace( - ' description: "Container image tag to deploy (e.g., v0.2.0)"\n required: true\n type: string', + ' description: "Container image tag to deploy (e.g., v0.2.0)"', ' description: |\n required: true\n type: string', ) - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) test('rejects escaped YAML property keys that decode to a different input contract', () => { @@ -154,6 +146,18 @@ test('rejects escaped YAML property keys that decode to a different input contra .replace(' required: true', ' "requ\\u0069red": false') .replace(' type: string', ' "t\\u0079pe": boolean') - assert.equal(hasExactParkedStagingGateOnBlock(workflow), false) - assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /exact reviewed/) + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) +}) + +test('rejects hiding the reviewed bytes inside a scalar before an effective release trigger', () => { + const workflow = canonicalWorkflow + .replace('name: CD Staging Gate', "name: '") + .replace( + '\npermissions:', + `\npermissions:\n contents: read\n'\n"on":\n workflow_dispatch:\n inputs:\n image_tag:\n required: true\n type: string\n skip_smoke:\n required: false\n type: boolean\n default: false\n release: { types: [published] }\npermissions:`, + ) + + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) }) From 4efd285251c699e937c5956193f1bbcfb704989d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 27 Jul 2026 14:19:59 +0100 Subject: [PATCH 7/7] Correct parked staging gate documentation Signed-off-by: Chris0Jeky --- docs/IMPLEMENTATION_MASTERPLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 8ae8967b1..819ba05b4 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -19,7 +19,7 @@ Companion Active Docs: ## Delivery update (2026-07-27, release workflow truth) -- **Manual-only parked staging gate (`#1228`):** `.github/workflows/cd-staging-gate.yml` no longer subscribes to `release: published`; its required-input `workflow_dispatch` path, jobs, permissions, and `production` environment binding remain available for deliberate operator runs. The repository does not currently configure that environment or required reviewers, so the workflow no longer calls the binding an approval gate; maintainer-owned activation protection is tracked in `#1504`. Active beta release lanes (`release-desktop.yml`, `ci-release.yml`, and `release-security.yml`) are unchanged, so publishing a release cannot start this parked staging path. +- **Manual-only parked staging gate (`#1228`):** `.github/workflows/cd-staging-gate.yml` no longer subscribes to `release: published`; its required-input `workflow_dispatch` path, jobs, permissions, and `production` environment binding remain defined for a future deliberate operator run. That parent workflow is not runnable on a clean hosted worker yet because it does not supply the Compose-required `TASKDECK_CONNECTORS_ENCRYPTION_KEY`; `#1506` owns the no-environment, no-deploy rehearsal and current-doc correction. The repository does not currently configure that environment or required reviewers, so the workflow no longer calls the binding an approval gate; maintainer-owned activation protection is tracked in `#1504`. Active beta release lanes (`release-desktop.yml`, `ci-release.yml`, and `release-security.yml`) are unchanged, so publishing a release cannot start this parked staging path. ## Delivery update (2026-07-26, agentic governance)