diff --git a/.github/workflows/cd-staging-gate.yml b/.github/workflows/cd-staging-gate.yml index 3d1d1744c..bcad3dea2 100644 --- a/.github/workflows/cd-staging-gate.yml +++ b/.github/workflows/cd-staging-gate.yml @@ -1,10 +1,10 @@ # ============================================================================= -# 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: -# - 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 +25,6 @@ on: required: false type: boolean default: false - release: - types: - - published permissions: contents: read @@ -58,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 @@ -189,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/.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/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index ab7bf1cc7..5cba0d932 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -22,6 +22,10 @@ Companion Active Docs: - **Post-`#1373` diagnostic successor (`#1512`):** structured, bounded correlation plus outer/last-inspected exception type, explicit truncation, aggregate-branch, and SQLite-code evidence is now available when the concurrent-card assertion fails. Normal middleware logs are metadata-only, the client still receives the generic GP-03 500, and no request/user/credential/exception-message content enters the diagnostic sink. Pre/post exact stress, the five-case historical/current concurrency matrix, and five CI-equivalent API runs stayed green and did not reproduce the original 500; therefore no causal exception, `SQLITE_BUSY` classification, retry, quarantine, or product fix is claimed. - **Still open:** retain `#1512` until a real recurrence identifies the cause and the narrow repair passes the full serialized backend suite plus exact-head Windows/Ubuntu CI. +## 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 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) - **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 f260c543f..77b1df831 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -596,7 +596,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` 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) @@ -1252,7 +1252,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/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index 1ef574713..801069560 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -133,6 +133,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 } ``` @@ -153,9 +154,14 @@ 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 ``` +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/docs/ops/DEPLOYMENT_WORKFLOW.md b/docs/ops/DEPLOYMENT_WORKFLOW.md index 831cae787..9784f6bb2 100644 --- a/docs/ops/DEPLOYMENT_WORKFLOW.md +++ b/docs/ops/DEPLOYMENT_WORKFLOW.md @@ -290,12 +290,12 @@ 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 +- 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/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..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). (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`. +> **⚠️ 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..9e5f90136 100644 --- a/scripts/check-github-ops-governance.mjs +++ b/scripts/check-github-ops-governance.mjs @@ -2,10 +2,14 @@ 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 expectedParkedStagingGateSha256 = '3b38f6eec8cc5e1b5ef486697bb4d746db97a5ce05baa8d96a3663de503dc110' + const requiredIssueTemplateFiles = [ '.github/ISSUE_TEMPLATE/bug_report.md', '.github/ISSUE_TEMPLATE/feature.md', @@ -140,10 +144,48 @@ async function validateProjectAutomationDocs() { } } +export function retainsReleaseEventHandling(workflowText) { + const normalizedReferences = workflowText.toLowerCase().replace(/[^a-z0-9_]+/g, '.') + return normalizedReferences.includes('event_name') || normalizedReferences.includes('github.event.release') +} + +export function hasExpectedParkedStagingGateWorkflow(workflowText) { + const normalized = workflowText.replace(/\r\n/g, '\n') + 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 (!hasExpectedParkedStagingGateWorkflow(workflowText)) { + workflowErrors.push( + `${workflowPath} must match the complete reviewed parked-workflow digest`, + ) + } + + 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() { await validateIssueTemplates() await validateIssueTemplateConfig() await validateProjectAutomationDocs() + await validateParkedStagingGateTriggers() if (errors.length > 0) { console.error('GitHub operations governance check failed:') @@ -156,7 +198,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..13e2dc3bf --- /dev/null +++ b/scripts/check-github-ops-governance.test.mjs @@ -0,0 +1,163 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +import { + hasExpectedParkedStagingGateWorkflow, + retainsReleaseEventHandling, + validateParkedStagingGateWorkflow, +} from './check-github-ops-governance.mjs' + +const canonicalWorkflow = readFileSync( + new URL('../.github/workflows/cd-staging-gate.yml', import.meta.url), + 'utf8', +) + +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), []) +}) + +test('rejects an inline flow-style release trigger', () => { + const workflow = `on: + workflow_dispatch: + inputs: + image_tag: + required: true + type: string + release: { types: [published] } +jobs: {} +` + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) +}) + +test('rejects a quoted release trigger', () => { + const workflow = `on: + workflow_dispatch: + "release": { types: [published] } +jobs: {} +` + + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) +}) + +test('rejects a comment-suffixed release trigger', () => { + const workflow = `on: + workflow_dispatch: + release: # publish events + types: [published] +jobs: {} +` + + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) +}) + +test('rejects an unfamiliar indentation-two trigger entry', () => { + const workflow = `on: + workflow_dispatch: + ? release +jobs: {} +` + + 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', () => { + 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) +}) + +test('detects a non-required image_tag input', () => { + const workflow = `on: + workflow_dispatch: + inputs: + image_tag: + required: false + type: string +jobs: {} +` + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) +}) + +test('detects a non-string image_tag input', () => { + const workflow = `on: + workflow_dispatch: + inputs: + image_tag: + required: true + type: boolean +jobs: {} +` + assert.equal(hasExpectedParkedStagingGateWorkflow(workflow), false) + assert.match(validateParkedStagingGateWorkflow(workflow).join('\n'), /reviewed parked-workflow digest/) +}) + +test('rejects a defaulted image_tag input', () => { + const workflow = `on: + workflow_dispatch: + inputs: + image_tag: + required: true + type: string + default: latest +jobs: {} +` + 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', () => { + const workflow = canonicalWorkflow.replace( + '\npermissions:', + '\n# A column-zero comment does not end the YAML mapping.\n release:\n types: [published]\npermissions:', + ) + + 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)"', + ' description: |\n required: true\n type: string', + ) + + 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', () => { + const workflow = canonicalWorkflow + .replace(' required: true', ' "requ\\u0069red": false') + .replace(' type: string', ' "t\\u0079pe": boolean') + + 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/) +})