diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 67c6d0c84..ed901df9f 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -16,9 +16,11 @@ steps from lexical policy checks. Checkout pins additionally belong to a reviewed allowlist whose current floor includes GitHub's fail-closed fork-PR protection for privileged events. The -audit prohibits `pull_request_target` entirely and rejects checkout's -`allow-unsafe-pr-checkout` opt-out; privileged work must remain isolated from -pull-request code rather than bypassing the upstream guard. +audit rejects `pull_request_target` except for the exact, metadata-only closed-PR +capacity reclaimer, and rejects checkout's `allow-unsafe-pr-checkout` opt-out. +That exception can only run the protected default-branch helper, has no shell, +cache, artifact, container, secret, or pull-request checkout ingress, and is +machine-checked down to its permissions, dependencies, and generated program. `workflow_run` is also a privileged trigger. Every handler must filter its upstream workflow to the literal canonical branch `main`, and checkout steps @@ -55,13 +57,11 @@ source. The audit recognizes the script inputs of `actions/github-script`, in those programs. Pass the value through step `env` and read it from the script's process environment instead. -Jobs that receive confidential repository secrets or a write-capable +Jobs that receive repository secrets or a write-capable `GITHUB_TOKEN` do not restore or save caches. This includes setup-action dependency caches, direct Actions caches, and external BuildKit cache imports: cache contents are unsigned mutable build input, while provenance only records -what the workflow produced. Read-only jobs may still cache locked dependencies; -the intentionally public legacy license key is not treated as a confidential -credential. +what the workflow produced. Read-only jobs may still cache locked dependencies. Passing data through `env` does not make it safe to append to the runner's `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, or `GITHUB_STATE` command files. @@ -73,7 +73,7 @@ and chooses a random multiline delimiter that cannot collide with the value. This prevents embedded newlines from creating additional outputs or environment entries. -Workflows triggered by `pull_request` cannot reference confidential repository +Workflows triggered by `pull_request` cannot reference repository secrets. Canonical governance therefore keeps its pull-request checks local to the public checkout. `canonical-private-governance.yml` performs cross-repo status, control-plane, subsystem-registry, subsystem-contract, mobile @@ -81,8 +81,8 @@ compatibility, and repo-governance checks only after a push to `main`, so unmerged pull-request code cannot replace the instructions that receive `WORKFLOW_PAT`. The public job still audits contract structure and every public path; only private path existence is deferred to that credential-isolated job. -`PULSE_LICENSE_PUBLIC_KEY` is the sole explicit PR exception because that -legacy secret value is intentionally non-confidential. +Public configuration is not exempt: pull-request workflows must use repository +variables or checked-in values rather than the secret context. ## Release Continuity diff --git a/.github/workflows/compile-release-payload.yml b/.github/workflows/compile-release-payload.yml index bb763a56e..9ba5deeed 100644 --- a/.github/workflows/compile-release-payload.yml +++ b/.github/workflows/compile-release-payload.yml @@ -68,6 +68,7 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' + package-manager-cache: false - name: Verify compiler capacity shell: bash diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 4bead5286..af2550c87 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -711,8 +711,7 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' - cache: 'npm' - cache-dependency-path: 'frontend-modern/package-lock.json' + package-manager-cache: false - name: Download verified frontend bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 6a44b7edc..d9af85e69 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -109,8 +109,6 @@ jobs: run: | docker build -t pulse:test --target e2e_runtime --build-arg GO_BUILD_TAGS="" . docker build -t pulse-mock-github:test ./tests/integration/mock-github-server - env: - PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - name: Start test containers working-directory: tests/integration @@ -259,8 +257,6 @@ jobs: run: | docker build -t pulse:test --target e2e_runtime --build-arg GO_BUILD_TAGS="" . docker build -t pulse-mock-github:test ./tests/integration/mock-github-server - env: - PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} - name: Start non-mock test container working-directory: tests/integration diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 5aec02bd0..e60cdcc2f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2141,6 +2141,11 @@ artifact-selection behaviour. secrets, and attacker-controlled event metadata must enter generated runner scripts through explicit environment variables; `${{ }}` interpolation in a `run` program is not an acceptable data boundary. + Exact-SHA payload compilation and pre-publication release smoke jobs must + explicitly disable setup-node automatic package-manager caching and must not + opt into setup-node dependency caching. Those jobs consume or qualify the + candidate at a release trust boundary, so mutable cache contents must not + become an undeclared release input. Whenever that policy changes, update the owning workflow/install proof files in `scripts/installtests/build_release_assets_test.go` and `scripts/release_control/release_promotion_policy_*` in the same slice. @@ -5034,11 +5039,17 @@ Every repository checkout in build, packaging, publication, qualification, recovery, and deployment automation uses the reviewed immutable `actions/checkout` v7.0.1 pin. That baseline refuses fork pull-request checkout on privileged events unless a workflow explicitly opts out; Pulse prohibits -that opt-out and the `pull_request_target` trigger. Dependency refreshes must -update the central workflow-trust allowlist and its regression proof together, -so a routine pin change cannot silently remove this release-automation trust -boundary. `scripts/check_workflow_trust.py`, -`scripts/tests/test_workflow_trust.py`, and +that opt-out. The sole `pull_request_target` exception is the machine-checked, +metadata-only closed-PR capacity reclaimer: it runs only the protected +default-branch helper and has no secret, checkout, cache, artifact, container, +or shell ingress. Pull-request workflows may not reference repository secrets, +including values treated as public configuration. Exact-SHA compilation and +pre-publication release smoke explicitly disable setup-node's automatic npm +cache and do not request dependency caches, preventing mutable cache state from +becoming release input. Dependency refreshes must update the central +workflow-trust allowlist and its regression proof together, so a routine pin +change cannot silently remove this release-automation trust boundary. +`scripts/check_workflow_trust.py`, `scripts/tests/test_workflow_trust.py`, and `scripts/installtests/build_release_assets_test.go` pin the policy and the release-workflow integration. diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index a5d0f45b4..166247333 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -142,9 +142,6 @@ WRITE_PERMISSION_RE = re.compile( r'''^\s+(?:[A-Za-z-]+|"[A-Za-z-]+"|'[A-Za-z-]+')\s*:\s*''' r'''(?:write|"write"|'write')\s*$''' ) -# This value is intentionally public and only uses secret storage as a legacy -# configuration mechanism. Confidential credentials have no PR exception. -NON_CONFIDENTIAL_PULL_REQUEST_SECRETS = frozenset({"PULSE_LICENSE_PUBLIC_KEY"}) CHECKOUT_PREFIX = "actions/checkout@" # These action inputs are programs, not ordinary data. GitHub's Actions # CodeQL models treat the same actions as code-injection sinks: expression @@ -714,15 +711,12 @@ def _is_untrusted_expression(value: str) -> bool: ) -def _has_confidential_secret_reference(lines: list[str]) -> bool: - """Return whether workflow lines can resolve a confidential secret.""" +def _has_secret_reference(lines: list[str]) -> bool: + """Return whether workflow lines can resolve any repository secret.""" for line in lines: for expression in EXPRESSION_RE.findall(line.split("#", 1)[0]): static_references = list(SECRET_CONTEXT_RE.finditer(expression)) - secret_names = { - match.group(1) or match.group(3) for match in static_references - } - if secret_names - NON_CONFIDENTIAL_PULL_REQUEST_SECRETS: + if static_references: return True if len(SECRET_CONTEXT_TOKEN_RE.findall(expression)) != len( static_references @@ -874,7 +868,7 @@ def _is_hardened_closed_pr_cancellation(path: Path, lines: list[str]) -> bool: ] if dependencies != list(SAFE_PULL_REQUEST_TARGET_ACTIONS): return False - if _has_confidential_secret_reference(lines): + if _has_secret_reference(lines): return False if any( re.match(r"^\s*(?:container|services|defaults|env)\s*:", line) @@ -1153,7 +1147,7 @@ def _audit_privileged_job_caches(path: Path, lines: list[str]) -> list[Finding]: for index, line in enumerate(lines) if (match := PERMISSIONS_RE.match(line.split("#", 1)[0])) ) - top_level_confidential_secret = _has_confidential_secret_reference( + top_level_secret = _has_secret_reference( lines[:jobs_index] + lines[jobs_end:] ) @@ -1176,8 +1170,8 @@ def _audit_privileged_job_caches(path: Path, lines: list[str]) -> list[Finding]: ) privileged = ( top_level_write - or top_level_confidential_secret - or _has_confidential_secret_reference(job_lines) + or top_level_secret + or _has_secret_reference(job_lines) or job_write ) if not privileged: @@ -1294,13 +1288,13 @@ def audit_workflow(path: Path) -> list[Finding]: if _has_trigger(lines, "pull_request"): for index, line in enumerate(lines): - if _has_confidential_secret_reference([line]): + if _has_secret_reference([line]): findings.append( Finding( path, index + 1, - "pull_request workflows must not reference confidential " - "repository secrets; isolate privileged work in a non-PR workflow", + "pull_request workflows must not reference repository " + "secrets; isolate privileged work in a non-PR workflow", ) ) diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 5ff1be820..2782a3fc5 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -3266,6 +3266,7 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { frontendBundleJob := workflowJobBlock(t, createWorkflow, "frontend_bundle") backendJob := workflowJobBlock(t, createWorkflow, "backend_tests") integrationJob := workflowJobBlock(t, createWorkflow, "integration_tests") + releaseSmokeJob := workflowJobBlock(t, createWorkflow, "release_smoke") validationJob := workflowJobBlock(t, createWorkflow, "validate_release_assets") privateStageJob := workflowJobBlock(t, createWorkflow, "stage_private_pro_runtime") readinessJob := workflowJobBlock(t, createWorkflow, "release_readiness") @@ -3278,6 +3279,8 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { helmPagesJob := workflowJobBlock(t, convergenceWorkflow, "publish_helm_pages") demoJob := workflowJobBlock(t, convergenceWorkflow, "update_stable_demo") compileJob := workflowJobBlock(t, compilerWorkflow, "compile-release-payload") + compileSetupNodeStep := workflowStepBlock(t, compileJob, "Set up Node.js") + releaseSmokeSetupNodeStep := workflowStepBlock(t, releaseSmokeJob, "Set up Node.js") obtainPayloadJob := workflowJobBlock(t, candidateWorkflow, "obtain-release-payload") candidateBuildJob := workflowJobBlock(t, candidateWorkflow, "build") compiledPayloadVerificationStep := workflowStepBlock(t, candidateBuildJob, "Verify exact-SHA compiled payload") @@ -3371,8 +3374,16 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { t.Fatalf("%s runner selection must not depend on the Windows-signing decision", label) } } - if !strings.Contains(compileJob, "cache: false") || strings.Contains(compileJob, "cache: 'npm'") { - t.Fatal("release compilation must avoid Actions cache archival") + for label, step := range map[string]string{ + "exact-SHA release compilation": compileSetupNodeStep, + "pre-publication release smoke": releaseSmokeSetupNodeStep, + } { + if !strings.Contains(step, "package-manager-cache: false") { + t.Fatalf("%s must disable setup-node automatic package-manager caching", label) + } + if strings.Contains(step, "\n cache:") || strings.Contains(step, "cache-dependency-path:") { + t.Fatalf("%s must not opt into setup-node dependency caching", label) + } } if strings.Contains(frontendBundleJob, "cache: 'npm'") { t.Fatal("PVE frontend bundle must use its persistent runner-local npm cache") diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index c88c29962..7b8244cc5 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -1119,7 +1119,7 @@ jobs: 3, ) - def test_pull_request_workflows_cannot_receive_confidential_secrets(self) -> None: + def test_pull_request_workflows_cannot_receive_repository_secrets(self) -> None: findings = self.audit( """on: push: @@ -1140,8 +1140,8 @@ jobs: """ ) self.assertEqual( - sum("must not reference confidential repository secrets" in finding for finding in findings), - 4, + sum("must not reference repository secrets" in finding for finding in findings), + 6, ) trusted_push = self.audit(