diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..b465694 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,18 @@ +self-hosted-runner: + labels: + - self-hosted-k8s + +paths: + .github/workflows/deploy-backend.yaml: + ignore: + - 'shellcheck reported issue in this script: SC2086:.+' + .github/workflows/deploy-cloudrun.yaml: + ignore: + - 'shellcheck reported issue in this script: SC2086:.+' + .github/workflows/i18n-instrument.yaml: + ignore: + - 'shellcheck reported issue in this script: SC2086:.+' + .github/workflows/i18n-pipeline.yaml: + ignore: + - 'shellcheck reported issue in this script: SC2001:.+' + - 'shellcheck reported issue in this script: SC2086:.+' diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb new file mode 100644 index 0000000..7f48fa1 --- /dev/null +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -0,0 +1,188 @@ +require "yaml" +require "open3" +require "tmpdir" +require "json" + +workflow_path = File.expand_path("../workflows/deploy-static-website.yaml", __dir__) +workflow = YAML.safe_load(File.read(workflow_path), aliases: true) +jobs = workflow.fetch("jobs") + +cancel_in_progress = workflow.fetch("concurrency").fetch("cancel-in-progress") +expected_cancel_policy = "${{ github.event_name == 'pull_request_target' }}" +abort "Production deploys must never be cancelled in progress" unless cancel_in_progress == expected_cancel_policy + +abort "Preview dependencies must not cross jobs through a package-store artifact" if jobs.key?("preview-dependencies") + +preview_policy = jobs.fetch("preview-policy") +abort "Preview policy must use a GitHub-hosted runner" unless preview_policy.fetch("runs-on") == "ubuntu-latest" +abort "Preview policy must not receive secrets" unless preview_policy.fetch("permissions") == { "contents" => "read" } +abort "Preview policy must expose a trusted output" unless preview_policy.fetch("outputs", {}).fetch("trusted", nil) == "${{ steps.policy.outputs.trusted }}" +abort "Preview policy must expose a trusted toolchain output" unless preview_policy.fetch("outputs", {}).fetch("toolchain_allowed", nil) == "${{ steps.policy.outputs.toolchain_allowed }}" +policy_step = preview_policy.fetch("steps").find { |step| step.fetch("id", "") == "policy" } +abort "Preview policy must compare actor and author inside the trusted shell" unless policy_step&.fetch("run", "")&.include?('[ "$ACTOR" = "$AUTHOR" ]') +abort "Preview policy must receive actor and author as data" unless policy_step&.fetch("env", {})&.slice("ACTOR", "AUTHOR") == { + "ACTOR" => "${{ github.actor }}", + "AUTHOR" => "${{ github.event.pull_request.user.login }}", +} +abort "Preview policy must authenticate its current permission lookup" unless policy_step&.fetch("env", {})&.fetch("GH_TOKEN", nil) == "${{ github.token }}" +abort "Preview policy must query current repository permission" unless policy_step&.fetch("run", "")&.include?('gh api "repos/${REPOSITORY}/collaborators/${ACTOR}/permission"') +abort "Preview policy must require write-capable permission" unless policy_step&.fetch("run", "")&.include?("admin|maintain|write)") +abort "Preview policy must not trust stale author association" if policy_step&.fetch("env", {})&.key?("AUTHOR_ASSOCIATION") +abort "Preview policy must require the trusted pull-request event" unless policy_step&.fetch("run", "")&.include?('[ "$EVENT_NAME" = "pull_request_target" ]') +abort "Preview policy must receive toolchain inputs as data" unless policy_step&.fetch("env", {})&.slice("NODE_VERSION", "PNPM_VERSION") == { + "NODE_VERSION" => "${{ inputs.node_version }}", + "PNPM_VERSION" => "${{ inputs.pnpm_version }}", +} +abort "Preview policy must allow only Node.js 22" unless policy_step&.fetch("run", "")&.include?('[ "$NODE_VERSION" = "22" ]') +abort "Preview policy must allow only reviewed pnpm versions" unless policy_step&.fetch("run", "")&.include?("10.28.2|10.32.1") + +build_preview = jobs.fetch("build-preview") +abort "Preview build must use a GitHub-hosted runner" unless build_preview.fetch("runs-on") == "ubuntu-latest" +abort "Preview build must not use a self-hosted container" if build_preview.key?("container") +abort "Preview build must not receive NPM_TOKEN at job scope" if build_preview.fetch("env", {}).key?("NPM_TOKEN") +abort "Preview build must allow live pull-request authorization lookup" unless build_preview.fetch("permissions", {}).fetch("pull-requests", nil) == "read" +preview_install = build_preview.fetch("steps").find { |step| step.fetch("name", "") == "Install dependencies with scoped registry credential" } +abort "Preview install must receive only the scoped NPM_TOKEN" unless preview_install&.fetch("env", {})&.fetch("NPM_TOKEN", nil) == "${{ secrets.NPM_TOKEN }}" +untrusted_preview_names = ["Verify credential isolation", "Rebuild dependencies", "Type check", "Lint", "Build preview"] +untrusted_preview_steps = build_preview.fetch("steps").select { |step| untrusted_preview_names.include?(step.fetch("name", "")) } +abort "Preview rebuild/check/build steps must explicitly clear NPM_TOKEN" unless untrusted_preview_steps.length == 5 && untrusted_preview_steps.all? { |step| step.fetch("env", {}).fetch("NPM_TOKEN", nil) == "" } +abort "Preview build must depend on the trusted policy" unless Array(build_preview.fetch("needs")).include?("preview-policy") +abort "Preview build must require the trusted policy output" unless build_preview.fetch("if") == "needs.preview-policy.outputs.trusted == 'true'" + +preview_steps = build_preview.fetch("steps") +dependency_validation_index = preview_steps.index { |step| step.fetch("name", "") == "Validate preview dependency inputs" } +authorization_index = preview_steps.index { |step| step.fetch("name", "") == "Revalidate preview authorization" } +credentialed_install_index = preview_steps.index { |step| step.fetch("name", "") == "Install dependencies with scoped registry credential" } +abort "Preview dependency inputs must be validated before credentialed install" unless dependency_validation_index && credentialed_install_index && dependency_validation_index < credentialed_install_index +abort "Preview authorization must be revalidated immediately before credentialed install" unless authorization_index && authorization_index + 1 == credentialed_install_index +dependency_validation = preview_steps.fetch(dependency_validation_index) +abort "Preview dependency validation must not receive NPM_TOKEN" if dependency_validation.fetch("env", {}).key?("NPM_TOKEN") +preview_authorization = preview_steps.fetch(authorization_index) +abort "Preview authorization recheck must not receive NPM_TOKEN" if preview_authorization.fetch("env", {}).key?("NPM_TOKEN") +abort "Preview authorization recheck must use pinned GitHub Script" unless preview_authorization.fetch("uses", "").match?(/\Aactions\/github-script@[0-9a-f]{40}\z/) +abort "Preview authorization recheck must use the job token" unless preview_authorization.fetch("with", {}).fetch("github-token", nil) == "${{ github.token }}" + +%w[build-preview build-production].each do |job_name| + steps = jobs.fetch(job_name).fetch("steps") + validation_index = steps.index { |step| step.fetch("name", "") == "Reject non-regular build outputs" } + upload_index = steps.index { |step| step.fetch("name", "") == "Upload static site artifact" } + abort "#{job_name} must validate build outputs before upload" unless validation_index && upload_index && validation_index < upload_index + + validation = steps.fetch(validation_index).fetch("run") + Dir.mktmpdir do |directory| + dist = File.join(directory, "dist") + Dir.mkdir(dist) + File.write(File.join(dist, "index.html"), "safe") + _stdout, stderr, status = Open3.capture3("bash", "-euo", "pipefail", "-c", validation, chdir: directory) + abort "#{job_name} must accept regular build outputs: #{stderr}" unless status.success? + + File.symlink("/proc/self/environ", File.join(dist, "environment.txt")) + _stdout, _stderr, status = Open3.capture3("bash", "-euo", "pipefail", "-c", validation, chdir: directory) + abort "#{job_name} must reject symlinks before upload" if status.success? + end +end + +%w[deploy-preview discord-thread-open discord-build-update].each do |job_name| + job = jobs.fetch(job_name) + abort "#{job_name} must depend on the trusted policy" unless Array(job.fetch("needs")).include?("preview-policy") + abort "#{job_name} must require the trusted policy output" unless job.fetch("if").include?("needs.preview-policy.outputs.trusted == 'true'") +end + +deploy_preview_steps = jobs.fetch("deploy-preview").fetch("steps") +deploy_authorization_index = deploy_preview_steps.index { |step| step.fetch("name", "") == "Revalidate preview authorization" } +deploy_oidc_index = deploy_preview_steps.index { |step| step.fetch("name", "") == "Authenticate to Google Cloud" } +abort "Preview authorization must be revalidated immediately before OIDC" unless deploy_authorization_index && deploy_authorization_index + 1 == deploy_oidc_index +deploy_authorization = deploy_preview_steps.fetch(deploy_authorization_index) +abort "Build and deploy must execute the same authorization recheck" unless preview_authorization.fetch("with").fetch("script") == deploy_authorization.fetch("with").fetch("script") + +authorization_script = preview_authorization.fetch("with").fetch("script") + base_pull_request = { + "state" => "open", + "user" => { "login" => "maintainer" }, + "head" => { + "sha" => "trusted-head", + "repo" => { "full_name" => "CellarNode/site" }, + }, + } + cases = { + "current trusted author" => [base_pull_request, "write", true], + "closed pull request" => [base_pull_request.merge("state" => "closed"), "write", false], + "changed head" => [base_pull_request.merge("head" => base_pull_request.fetch("head").merge("sha" => "changed-head")), "write", false], + "forked head" => [base_pull_request.merge("head" => base_pull_request.fetch("head").merge("repo" => { "full_name" => "attacker/site" })), "write", false], + "different author" => [base_pull_request.merge("user" => { "login" => "other-user" }), "write", false], + "revoked permission" => [base_pull_request, "read", false], + } + + cases.each do |name, (pull_request, permission, expected)| + _stdout, _stderr, status = Open3.capture3( + { + "ACTOR" => "maintainer", + "AUTHORIZATION_SCRIPT" => authorization_script, + "CURRENT_PERMISSION" => permission, + "EXPECTED_HEAD" => "trusted-head", + "PR_JSON" => JSON.generate(pull_request), + "PR_NUMBER" => "42", + "REPOSITORY" => "CellarNode/site", + }, + "node", "-e", <<~'JAVASCRIPT', + const AsyncFunction = Object.getPrototypeOf(async () => null).constructor; + const pullRequest = JSON.parse(process.env.PR_JSON); + const github = { + rest: { + pulls: { get: async () => ({ data: pullRequest }) }, + repos: { + getCollaboratorPermissionLevel: async () => ({ + data: { permission: process.env.CURRENT_PERMISSION }, + }), + }, + }, + }; + const context = { repo: { owner: 'CellarNode', repo: 'site' } }; + const core = { setFailed: message => { throw new Error(message); } }; + new AsyncFunction('github', 'context', 'core', process.env.AUTHORIZATION_SCRIPT)(github, context, core) + .catch(error => { console.error(error.message); process.exitCode = 1; }); + JAVASCRIPT + ) + abort "#{name}: expected accepted=#{expected}, got accepted=#{status.success?}" unless status.success? == expected + end + +abort "Preview lifecycle must not bind an unavailable sender field" if File.read(workflow_path).include?("github.event.sender.login") +abort "Preview lifecycle must not trust stale author association" if File.read(workflow_path).include?("author_association") + +build_production = jobs.fetch("build-production") +abort "Production build must use a GitHub-hosted runner" unless build_production.fetch("runs-on") == "ubuntu-latest" +abort "Production build must not use a self-hosted container" if build_production.key?("container") +abort "Production build must depend on toolchain policy" unless Array(build_production.fetch("needs")).include?("preview-policy") +abort "Production build must require approved toolchain" unless build_production.fetch("if").include?("needs.preview-policy.outputs.toolchain_allowed == 'true'") + +deploy_production = jobs.fetch("deploy-production") +abort "Production deploy must use the self-hosted runner" unless deploy_production.fetch("runs-on") == "self-hosted-k8s" + +deploy_steps = deploy_production.fetch("steps") +abort "Production deploy must not check out source" if deploy_steps.any? { |step| step.fetch("uses", "").start_with?("actions/checkout@") } + +{ + "deploy-production" => "Deploy to production", + "deploy-preview" => "Deploy preview", +}.each do |job_name, step_name| + deploy_step = jobs.fetch(job_name).fetch("steps").find { |step| step.fetch("name", "") == step_name } + storage_parallelism = deploy_step&.fetch("env", {})&.slice( + "CLOUDSDK_STORAGE_PROCESS_COUNT", + "CLOUDSDK_STORAGE_THREAD_COUNT", + ) + expected_serial_execution = { + "CLOUDSDK_STORAGE_PROCESS_COUNT" => "1", + "CLOUDSDK_STORAGE_THREAD_COUNT" => "1", + } + abort "#{step_name} must serialize gcloud storage workers" unless storage_parallelism == expected_serial_execution +end + +cleanup_guard = jobs.fetch("cleanup").fetch("if") +abort "Preview cleanup must survive author offboarding" if cleanup_guard.include?("author_association") + +validation_path = File.expand_path("../workflows/validate-static-deploy.yaml", __dir__) +validation = YAML.safe_load(File.read(validation_path), aliases: true) +validation_checkout = validation.fetch("jobs").fetch("lock-validator").fetch("steps").find { |step| step.fetch("uses", "").start_with?("actions/checkout@") } +abort "Validation checkout must disable persisted credentials" unless validation_checkout&.fetch("with", {})&.fetch("persist-credentials", nil) == false + +puts "Static deploy job boundaries passed" diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb new file mode 100644 index 0000000..87e1ef4 --- /dev/null +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -0,0 +1,432 @@ +require "open3" +require "rbconfig" +require "tempfile" +require "tmpdir" +require "yaml" + +workflow = File.read(File.expand_path("../workflows/deploy-static-website.yaml", __dir__)) +match = workflow.match(/cat > "\$LOCK_VALIDATOR" <<'RUBY'\n(?.*?)^\s+RUBY$/m) +abort "Lock validator heredoc not found" unless match + +validator = match[:body].gsub(/^ {10}/, "") +fixtures = { + "registry resolution" => [<<~YAML, true], + lockfileVersion: '9.0' + importers: {} + packages: + '@scope/package@1.0.0': + resolution: + integrity: sha512-safe + YAML + "null lock root" => ["null\n", false], + "array lock root" => ["- unexpected\n", false], + "unrelated lock root" => ["foo: bar\n", false], + "missing importers" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: {} + YAML + "unsupported lockfile version" => [<<~YAML, false], + lockfileVersion: '8.0' + importers: {} + YAML + "non-mapping packages" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: unexpected + YAML + "non-mapping snapshots" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + snapshots: [] + YAML + "empty resolution" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + '@scope/package@1.0.0': + resolution: {} + YAML + "missing integrity value" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + '@scope/package@1.0.0': + resolution: + integrity: + YAML + "structured integrity value" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + '@scope/package@1.0.0': + resolution: + integrity: + digest: sha512-safe + YAML + "array integrity value" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + '@scope/package@1.0.0': + resolution: + integrity: + - sha512-safe + YAML + "unknown integrity algorithm" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + '@scope/package@1.0.0': + resolution: + integrity: sha999-safe + YAML + "workspace importer reference" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + local-package: + specifier: workspace:* + version: link:../local-package + YAML + "patch importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: patch:dep@npm%3A1.0.0#hash + YAML + "portal importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: portal:../evil + YAML + "relative tarball importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: ../evil.tgz + YAML + "unknown protocol importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: exec:payload + YAML + "repository shorthand importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: attacker/package + YAML + "npm registry alias importer locator" => [<<~YAML, true], + lockfileVersion: '9.0' + importers: + .: + dependencies: + aliased-package: + specifier: npm:public-package@1.0.0 + version: public-package@1.0.0 + YAML + "raw git protocol" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + git-package@1.0.0: + resolution: + type: git + repo: git://evil.example/repo.git + YAML + "structured git resolution" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + git-package@1.0.0: + resolution: + type: git + repo: registry.example.invalid/repo + YAML + "unknown resolution shape" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + external-package@1.0.0: + resolution: + path: external-package + YAML + "tarball resolution" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + external-package@1.0.0: + resolution: + tarball: https://evil.example/package.tgz + YAML + "directory resolution" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + external-package@1.0.0: + resolution: + directory: ../external-package + YAML + "allowed private package" => [<<~YAML, true], + lockfileVersion: '9.0' + importers: {} + packages: + '@cellarnode/ui@0.154.0': + resolution: + integrity: sha512-safe + YAML + "unknown private package" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: + '@cellarnode/internal-secrets@1.0.0': + resolution: + integrity: sha512-safe + YAML + "aliased unknown private package" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + hidden-package: + specifier: npm:@cellarnode/internal-secrets@1.0.0 + version: '@cellarnode/internal-secrets@1.0.0' + packages: + '@cellarnode/internal-secrets@1.0.0': + resolution: + integrity: sha512-safe + YAML +} + +fixtures.each do |name, (lock, expected)| + Tempfile.create(["pnpm-lock", ".yaml"]) do |file| + file.write(lock) + file.flush + _stdout, _stderr, status = Open3.capture3( + RbConfig.ruby, + "-", + file.path, + stdin_data: validator, + ) + actual = status.success? + abort "#{name}: expected accepted=#{expected}, got accepted=#{actual}" unless actual == expected + end +end + +parsed_workflow = YAML.safe_load(workflow, aliases: true) +dependency_scripts = %w[build-preview build-production].to_h do |job_name| + steps = parsed_workflow.fetch("jobs").fetch(job_name).fetch("steps") + validation_index = steps.index do |step| + env = step.fetch("env", {}) + env.key?("MANIFEST_VALIDATOR") && env.key?("LOCK_VALIDATOR") && !env.key?("NPM_TOKEN") + end + install_index = steps.index { |step| step["name"] == "Install dependencies with scoped registry credential" } + abort "#{job_name}: credential-free dependency validation step not found" unless validation_index + abort "#{job_name}: credentialed install step not found" unless install_index + abort "#{job_name}: dependency validation must run before credentialed install" unless validation_index < install_index + + validation_step = steps.fetch(validation_index) + abort "#{job_name}: dependency validation must not receive NPM_TOKEN" if validation_step.fetch("env", {}).key?("NPM_TOKEN") + + install_script = steps.fetch(install_index).fetch("run").sub( + "${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }}", + "--no-frozen-lockfile", + ) + [job_name, [validation_step.fetch("run"), install_script]] +end + +abort "Preview and production dependency validation must use the same trusted script" unless dependency_scripts.values.map(&:first).uniq.one? + +manifest_fixtures = { + "unknown private manifest package" => ["@cellarnode/internal-secrets", "1.0.0", false], + "aliased unknown private manifest package" => ["hidden-package", "npm:@cellarnode/internal-secrets@1.0.0", false], + "allowed private manifest package" => ["@cellarnode/ui", "1.0.0", true], + "allowed public registry range" => ["public-package", "^1.0.0", true], + "aliased public manifest package" => ["@cellarnode/ui", "npm:evil-package@1.0.0", false], + "git manifest package" => ["public-package", "git+https://evil.example/package.git", false], + "file manifest package" => ["public-package", "file:../package", false], + "workspace manifest package" => ["public-package", "workspace:*", false], + "github shorthand manifest package" => ["public-package", "attacker/package", false], + "tarball manifest package" => ["public-package", "package.tgz", false], + "tar archive manifest package" => ["public-package", "package.tar", false], + "unknown protocol manifest package" => ["public-package", "exec:payload", false], + "git workspace override" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + overrides: + public-package: git+https://evil.example/package.git + YAML + ], + "external workspace config dependency" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + configDependencies: + malicious-config: https://evil.example/config.tgz + YAML + ], + "registry workspace override" => [ + "public-package", + "^1.0.0", + true, + <<~YAML, + overrides: + "@radix-ui/react-dismissable-layer": "1.1.15" + YAML + ], + "external workspace catalog" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + catalogs: + default: + public-package: https://evil.example/package.tgz + YAML + ], + "external workspace package extension" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + packageExtensions: + "public-package@1.0.0": + dependencies: + injected-package: git+https://evil.example/package.git + YAML + ], + "workspace dependency patch" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + patchedDependencies: + "public-package@1.0.0": patches/public-package.patch + YAML + ], + "workspace named registry" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + namedRegistries: + attacker: https://evil.example/ + YAML + ], + "workspace build allowlist" => [ + "public-package", + "^1.0.0", + true, + <<~YAML, + onlyBuiltDependencies: + - "@swc/core" + - esbuild + YAML + ], +} + +dependency_scripts.each do |job_name, (validation_script, non_frozen_script)| + manifest_fixtures.each do |name, (package, version, expected_install, workspace)| + Dir.mktmpdir("cel1328-manifest-boundary") do |directory| + File.write( + File.join(directory, "package.json"), + <<~JSON, + { + "dependencies": { + "#{package}": "#{version}" + } + } + JSON + ) + File.write( + File.join(directory, "pnpm-lock.yaml"), + <<~YAML, + lockfileVersion: '9.0' + importers: {} + packages: + '@scope/package@1.0.0': + resolution: + integrity: sha512-safe + YAML + ) + File.write(File.join(directory, "pnpm-workspace.yaml"), workspace) if workspace + + bin_directory = File.join(directory, "bin") + Dir.mkdir(bin_directory) + marker = File.join(directory, "pnpm-invoked") + manifest_validator = File.join(directory, "manifest-validator.rb") + lock_validator = File.join(directory, "lock-validator.rb") + pnpm = File.join(bin_directory, "pnpm") + File.write(pnpm, <<~SH) + #!/bin/sh + : > "$PNPM_MARKER" + SH + File.chmod(0o755, pnpm) + + validator_env = { + "MANIFEST_VALIDATOR" => manifest_validator, + "LOCK_VALIDATOR" => lock_validator, + } + _stdout, validation_stderr, validation_status = Open3.capture3( + validator_env, + "bash", + "-euo", + "pipefail", + "-c", + validation_script, + chdir: directory, + ) + + if expected_install + abort "#{job_name} #{name}: credential-free validation failed: #{validation_stderr}" unless validation_status.success? + else + abort "#{job_name} #{name}: credential-free validation unexpectedly succeeded" if validation_status.success? + abort "#{job_name} #{name}: package manager ran before manifest rejection" if File.exist?(marker) + abort "#{job_name} #{name}: wrong rejection: #{validation_stderr}" unless validation_stderr.include?("manifest contains a disallowed package or source") + next + end + + _stdout, stderr, status = Open3.capture3( + { + "NPM_CONFIG_USERCONFIG" => File.join(directory, "preview-npmrc"), + "NPM_TOKEN" => "test-token", + "PATH" => "#{bin_directory}:#{ENV.fetch("PATH")}", + "PNPM_MARKER" => marker, + "MANIFEST_VALIDATOR" => manifest_validator, + "LOCK_VALIDATOR" => lock_validator, + }, + "bash", + "-euo", + "pipefail", + "-c", + non_frozen_script, + chdir: directory, + ) + + abort "#{job_name} #{name}: install did not run successfully: #{stderr}" unless status.success? && File.exist?(marker) + end + end +end + +puts "Lock validator fixtures passed: #{fixtures.length}; manifest fixtures passed: #{manifest_fixtures.length * dependency_scripts.length}" diff --git a/.github/tests/discord-metadata-boundary.test.rb b/.github/tests/discord-metadata-boundary.test.rb new file mode 100644 index 0000000..4bff440 --- /dev/null +++ b/.github/tests/discord-metadata-boundary.test.rb @@ -0,0 +1,16 @@ +workflow_path = File.expand_path("../workflows/discord-notify.yaml", __dir__) +workflow = File.read(workflow_path) + +abort "Discord metadata must ignore non-bot comments" unless workflow.include?("c.user?.login !== 'github-actions[bot]'") +abort "Discord metadata must validate allowed keys" unless workflow.include?("allowedMetadataKeys") +abort "Discord metadata must validate Discord snowflake IDs" unless workflow.include?("discordSnowflake") +abort "Trusted GitHub comment ID must override parsed metadata" unless workflow.include?("return { ...metadata, commentId: c.id };") +abort "Parsed metadata must never override trusted comment ID" if workflow.include?("return { commentId: c.id, ...JSON.parse(json) };") + +deploy_metadata = workflow[/listPullRequestsAssociatedWithCommit[\s\S]*?\/\/ Get commit message/] +abort "Deploy metadata must ignore non-bot comments" unless deploy_metadata&.include?("c.user?.login !== 'github-actions[bot]'") +abort "Deploy metadata must use the shared parser" unless deploy_metadata&.include?("const metadata = parseMetadata(c.body ?? '');") +abort "Deploy metadata must continue past invalid or incomplete comments" unless deploy_metadata&.include?("if (metadata?.threadId) {") +abort "Deploy metadata must not parse marker fragments directly" if deploy_metadata&.include?("split(META_TAG)") + +puts "Discord metadata boundary passed" diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 8fe5e94..d07b02c 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -36,6 +36,11 @@ on: required: false type: string default: "22" + pnpm_version: + description: "Trusted pnpm version used for preview builds" + required: false + type: string + default: "10.32.1" discord_tags: description: "Comma-separated Discord forum tag names (e.g. admin,importer)" required: false @@ -43,211 +48,724 @@ on: default: "" secrets: NPM_TOKEN: - description: "npm token used for private @cellarnode/* package installs (.npmrc env-var substitution)" - required: false + description: "Read-only npm token used to fetch private @cellarnode packages" + required: true DISCORD_WEBHOOK_URL: description: "Discord webhook credential" required: false +concurrency: + group: static-deploy-${{ inputs.website_slug }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} + env: PROJECT_ID: festive-terrain-478011-h0 GCS_BUCKET: websites-deploy jobs: - build-deploy: - if: github.event.action != 'closed' - # Self-hosted: site builds were the org's remaining paid-GHA-minutes bulk. - # Prebaked ci-runner image = no per-job node/pnpm/gcloud downloads; npm - # rides the in-region AR mirror. All repos are private, and fork PRs are - # already build-only via the should_deploy flag. + preview-policy: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + trusted: ${{ steps.policy.outputs.trusted }} + toolchain_allowed: ${{ steps.policy.outputs.toolchain_allowed }} + steps: + - name: Evaluate preview trust boundary + id: policy + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + ACTOR: ${{ github.actor }} + AUTHOR: ${{ github.event.pull_request.user.login }} + NODE_VERSION: ${{ inputs.node_version }} + PNPM_VERSION: ${{ inputs.pnpm_version }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + same_repository=false + if [ "$HEAD_REPOSITORY" = "$REPOSITORY" ]; then + same_repository=true + fi + + actor_matches_author=false + if [ "$ACTOR" = "$AUTHOR" ]; then + actor_matches_author=true + fi + + toolchain_allowed=false + if [ "$NODE_VERSION" = "22" ]; then + case "$PNPM_VERSION" in + 10.28.2|10.32.1) toolchain_allowed=true ;; + esac + fi + + permission_allowed=false + if [ "$EVENT_NAME" = "pull_request_target" ] && + [ "$same_repository" = "true" ] && + [ "$actor_matches_author" = "true" ]; then + permission="$(gh api "repos/${REPOSITORY}/collaborators/${ACTOR}/permission" --jq .permission)" + case "$permission" in + admin|maintain|write) permission_allowed=true ;; + esac + fi + + trusted=false + if [ "$EVENT_NAME" = "pull_request_target" ] && + [ "$EVENT_ACTION" != "closed" ] && + [ "$same_repository" = "true" ] && + [ "$permission_allowed" = "true" ] && + [ "$actor_matches_author" = "true" ] && + [ "$toolchain_allowed" = "true" ]; then + trusted=true + fi + + printf 'trusted=%s\n' "$trusted" >> "$GITHUB_OUTPUT" + printf 'toolchain_allowed=%s\n' "$toolchain_allowed" >> "$GITHUB_OUTPUT" + printf 'preview policy: event=%s action=%s same_repository=%s permission_allowed=%s actor_matches_author=%s toolchain_allowed=%s trusted=%s\n' \ + "$EVENT_NAME" "$EVENT_ACTION" "$same_repository" "$permission_allowed" "$actor_matches_author" "$toolchain_allowed" "$trusted" + + build-preview: + needs: preview-policy + if: needs.preview-policy.outputs.trusted == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + + steps: + - name: Set up pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: ${{ inputs.pnpm_version }} + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: ${{ inputs.node_version }} + + - name: Checkout pull-request source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Validate preview dependency inputs + shell: bash + env: + MANIFEST_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-manifest-validator.rb + LOCK_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-lock-validator.rb + run: &dependency-validation | + cat > "$MANIFEST_VALIDATOR" <<'RUBY' + require "find" + require "json" + require "yaml" + + private_package = /@cellarnode\/([a-z0-9._-]+)/i + dependency_sections = %w[ + catalog + catalogs + configDependencies + dependencies + devDependencies + optionalDependencies + overrides + packageExtensions + patchedDependencies + peerDependencies + resolutions + ].freeze + protocol_dependency_source = /\A[a-z][a-z0-9+.-]*:/i + path_or_archive_dependency_source = /[\\\/]|\.(?:tgz|tar(?:\.gz)?)\z/i + allowed_private_packages = %w[ + auth + beverage-utils + elabel-compliance + finance + i18n + ui + ].freeze + failures = [] + manifests = [] + Find.find(".") do |path| + if File.directory?(path) + Find.prune if %w[./.git ./node_modules].include?(path) + next + end + next unless File.basename(path) == "package.json" + + abort "Dependency manifest must be a regular file: #{path}" if File.symlink?(path) || !File.file?(path) + manifests << path + end + abort "Root package.json must be a regular file" unless manifests.include?("./package.json") + + visit = lambda do |value, path, keys| + case value + when Hash + value.each do |key, child| + child_keys = keys + [key.to_s] + key.to_s.scan(private_package).flatten.each do |package| + failures << "#{path}:#{child_keys.join('.')}" unless allowed_private_packages.include?(package.downcase) + end + visit.call(child, path, child_keys) + end + when Array + value.each_with_index { |child, index| visit.call(child, path, keys + [index.to_s]) } + when String + value.scan(private_package).flatten.each do |package| + failures << "#{path}:#{keys.join('.')}" unless allowed_private_packages.include?(package.downcase) + end + if keys.any? { |key| dependency_sections.include?(key) } && + (protocol_dependency_source.match?(value) || path_or_archive_dependency_source.match?(value)) + failures << "#{path}:#{keys.join('.')}" + end + end + end + + manifests.each { |path| visit.call(JSON.parse(File.read(path)), path, []) } + workspace_path = "./pnpm-workspace.yaml" + if File.exist?(workspace_path) + abort "pnpm-workspace.yaml must be a regular file" if File.symlink?(workspace_path) || !File.file?(workspace_path) + workspace = YAML.safe_load(File.read(workspace_path), aliases: true) + abort "pnpm-workspace.yaml must contain a mapping" unless workspace.is_a?(Hash) + workspace.each_key do |key| + name = key.to_s + if %w[namedRegistries registries registry].include?(name) || name.end_with?(":registry") + failures << "#{workspace_path}:#{name}" + end + end + visit.call(workspace, workspace_path, []) + end + abort "Dependency manifest contains a disallowed package or source at #{failures.uniq.join(', ')}" unless failures.empty? + RUBY + + cat > "$LOCK_VALIDATOR" <<'RUBY' + require "yaml" + + path = ARGV.fetch(0) + non_registry_protocol = /(?:\A|@)(?!npm:)[a-z][a-z0-9+.-]*:/i + relative_archive_source = /\A(?:\.\.?[\\\/]|~[\\\/]|[\\\/])|\.(?:tgz|tar(?:\.gz)?)\z/i + repository_shorthand = /\A[a-z0-9._-]+(?:[\\\/][a-z0-9._-]+)+(?:#.*)?\z/i + private_package = /@cellarnode\/([a-z0-9._-]+)/i + dependency_sections = %w[dependencies devDependencies optionalDependencies peerDependencies].freeze + allowed_private_packages = %w[ + auth + beverage-utils + elabel-compliance + finance + i18n + ui + ].freeze + failures = [] + validate_private_packages = lambda do |text, keys| + text.to_s.scan(private_package).flatten.each do |package| + failures << keys.join(".") unless allowed_private_packages.include?(package.downcase) + end + end + visit = lambda do |value, keys| + case value + when Hash + value.each do |key, child| + name = key.to_s + child_keys = keys + [name] + validate_private_packages.call(name, child_keys) + if name == "resolution" + valid_resolution = child.is_a?(Hash) && + child.keys.map(&:to_s) == ["integrity"] && + child["integrity"].is_a?(String) && + child["integrity"].match?(/\Asha(?:1|256|384|512)-[A-Za-z0-9+\/]+={0,2}\z/) + failures << child_keys.join(".") unless valid_resolution + end + if non_registry_protocol.match?(name) || relative_archive_source.match?(name) + failures << child_keys.join(".") + end + if keys.last == "resolution" && name != "integrity" + failures << child_keys.join(".") + end + visit.call(child, child_keys) + end + when Array + value.each_with_index { |child, index| visit.call(child, keys + [index.to_s]) } + when String + validate_private_packages.call(value, keys) + if non_registry_protocol.match?(value) || + relative_archive_source.match?(value) || + (keys.any? { |key| dependency_sections.include?(key) } && repository_shorthand.match?(value)) + failures << keys.join(".") + end + end + end + + lock = YAML.safe_load(File.read(path), aliases: true) + abort "Dependency lock must contain a mapping" unless lock.is_a?(Hash) + abort "Dependency lock must use lockfile version 9.0" unless lock["lockfileVersion"].to_s == "9.0" + abort "Dependency lock importers must contain a mapping" unless lock["importers"].is_a?(Hash) + %w[packages snapshots].each do |section| + abort "Dependency lock #{section} must contain a mapping" if lock.key?(section) && !lock[section].is_a?(Hash) + end + + visit.call(lock, []) + abort "Dependency lock contains a disallowed source or private package at #{failures.uniq.join(', ')}" unless failures.empty? + RUBY + + ruby "$MANIFEST_VALIDATOR" + if [ ! -f pnpm-lock.yaml ] || [ -L pnpm-lock.yaml ]; then + echo "pnpm-lock.yaml must be a regular file" >&2 + exit 1 + fi + ruby "$LOCK_VALIDATOR" pnpm-lock.yaml + + - name: Revalidate preview authorization + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + env: + ACTOR: ${{ github.actor }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + with: + github-token: ${{ github.token }} + script: &preview-authorization | + const actor = process.env.ACTOR; + const expectedHead = process.env.EXPECTED_HEAD; + const prNumber = Number(process.env.PR_NUMBER); + const repository = process.env.REPOSITORY; + const { data: pullRequest } = await github.rest.pulls.get({ + ...context.repo, + pull_number: prNumber, + }); + const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: actor, + }); + const authorized = pullRequest.state === 'open' && + pullRequest.user?.login === actor && + pullRequest.head.repo?.full_name === repository && + pullRequest.head.sha === expectedHead && + ['admin', 'maintain', 'write'].includes(access.permission); + if (!authorized) { + core.setFailed('Preview authorization is no longer valid'); + } + + - name: Install dependencies with scoped registry credential + shell: bash + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-preview-npmrc + MANIFEST_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-manifest-validator.rb + LOCK_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-lock-validator.rb + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "NPM_TOKEN is required to fetch private @cellarnode packages" >&2 + exit 1 + fi + rm -f -- .npmrc .pnpmfile.cjs "$NPM_CONFIG_USERCONFIG" + trap 'rm -f "$NPM_CONFIG_USERCONFIG" "$MANIFEST_VALIDATOR" "$LOCK_VALIDATOR"' EXIT + umask 077 + printf '%s\n' \ + '@cellarnode:registry=https://registry.npmjs.org/' \ + "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ + > "$NPM_CONFIG_USERCONFIG" + pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile + ruby "$LOCK_VALIDATOR" pnpm-lock.yaml + + - name: Verify credential isolation + env: + NPM_TOKEN: "" + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-preview-npmrc + run: | + test -z "$NPM_TOKEN" + test ! -e "$NPM_CONFIG_USERCONFIG" + test ! -e .npmrc + test ! -e .pnpmfile.cjs + + - name: Rebuild dependencies + env: + NPM_TOKEN: "" + run: pnpm rebuild + + - name: Type check + if: inputs.typecheck + env: + NPM_TOKEN: "" + run: pnpm tsc --noEmit + + - name: Lint + if: inputs.lint + env: + NPM_TOKEN: "" + run: pnpm lint + + - name: Build preview + shell: bash + env: + BUILD_ENV_VARS: ${{ inputs.build_env }} + NPM_TOKEN: "" + run: | + if [ -n "$BUILD_ENV_VARS" ]; then + while IFS= read -r line; do + [ -n "$line" ] && export "${line?}" + done <<< "$BUILD_ENV_VARS" + fi + pnpm build + + - name: Reject non-regular build outputs + shell: bash + run: | + test -d dist + if find dist \( -type l -o -type b -o -type c -o -type p -o -type s \) -print -quit | grep -q .; then + echo "Static artifact contains a non-regular entry" >&2 + exit 1 + fi + + - name: Upload static site artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + if-no-files-found: error + retention-days: 1 + + build-production: + needs: preview-policy + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.preview-policy.outputs.toolchain_allowed == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Set up pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: ${{ inputs.pnpm_version }} + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: ${{ inputs.node_version }} + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + + - name: Validate production dependency inputs + shell: bash + env: + MANIFEST_VALIDATOR: ${{ runner.temp }}/cellarnode-production-manifest-validator.rb + LOCK_VALIDATOR: ${{ runner.temp }}/cellarnode-production-lock-validator.rb + run: *dependency-validation + + - name: Install dependencies with scoped registry credential + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-production-npmrc + MANIFEST_VALIDATOR: ${{ runner.temp }}/cellarnode-production-manifest-validator.rb + LOCK_VALIDATOR: ${{ runner.temp }}/cellarnode-production-lock-validator.rb + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "NPM_TOKEN is required to fetch private @cellarnode packages" >&2 + exit 1 + fi + rm -f -- .npmrc .pnpmfile.cjs "$NPM_CONFIG_USERCONFIG" + trap 'rm -f "$NPM_CONFIG_USERCONFIG" "$MANIFEST_VALIDATOR" "$LOCK_VALIDATOR"' EXIT + umask 077 + printf '%s\n' \ + '@cellarnode:registry=https://registry.npmjs.org/' \ + "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ + > "$NPM_CONFIG_USERCONFIG" + pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile + ruby "$LOCK_VALIDATOR" pnpm-lock.yaml + + - name: Verify credential isolation + env: + NPM_TOKEN: "" + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-production-npmrc + run: | + test -z "$NPM_TOKEN" + test ! -e "$NPM_CONFIG_USERCONFIG" + test ! -e .npmrc + test ! -e .pnpmfile.cjs + + - name: Rebuild dependencies + env: + NPM_TOKEN: "" + run: pnpm rebuild + + - name: Type check + if: inputs.typecheck + env: + NPM_TOKEN: "" + run: pnpm tsc --noEmit + + - name: Lint + if: inputs.lint + env: + NPM_TOKEN: "" + run: pnpm lint + + - name: Build production + shell: bash + env: + BUILD_ENV_VARS: ${{ inputs.build_env }} + NPM_TOKEN: "" + run: | + if [ -n "$BUILD_ENV_VARS" ]; then + while IFS= read -r line; do + [ -n "$line" ] && export "${line?}" + done <<< "$BUILD_ENV_VARS" + fi + pnpm build + + - name: Reject non-regular build outputs + shell: bash + run: | + test -d dist + if find dist \( -type l -o -type b -o -type c -o -type p -o -type s \) -print -quit | grep -q .; then + echo "Static artifact contains a non-regular entry" >&2 + exit 1 + fi + + - name: Upload static site artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + if-no-files-found: error + retention-days: 1 + + deploy-production: + needs: build-production + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: self-hosted-k8s container: - image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner:latest - outputs: - deployed: ${{ steps.flags.outputs.should_deploy }} - # Forward NPM_TOKEN so .npmrc env-var substitution finds it during - # pnpm install. Caller side declares the secret via `secrets: inherit` - # (or an explicit `secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }}` map). - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 + permissions: + contents: read + id-token: write + + steps: + - name: Download static site artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + - name: Reject non-regular artifact entries + shell: bash + run: | + test -d dist + if find dist \( -type l -o -type b -o -type c -o -type p -o -type s \) -print -quit | grep -q .; then + echo "Static artifact contains a non-regular entry" >&2 + exit 1 + fi + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + project_id: ${{ env.PROJECT_ID }} + workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' + service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + with: + skip_install: true + + - name: Warm gcloud credential cache + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + run: | + for i in 1 2 3 4 5; do + gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 + echo "gcloud warm-up attempt ${i}/5 failed; retrying in $((i * 10))s" + sleep $((i * 10)) + done + echo "gcloud warm-up failed after 5 attempts" >&2 + exit 1 + + - name: Deploy to production + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + CLOUDSDK_STORAGE_PROCESS_COUNT: '1' + CLOUDSDK_STORAGE_THREAD_COUNT: '1' + WEBSITE: ${{ inputs.website }} + run: | + gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ + ./dist "gs://${GCS_BUCKET}/${WEBSITE}" + + deploy-preview: + needs: [preview-policy, build-preview] + if: needs.preview-policy.outputs.trusted == 'true' + runs-on: self-hosted-k8s + container: + image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 permissions: contents: read id-token: write pull-requests: write steps: - - uses: actions/checkout@v4 - - # Fork PRs can't authenticate via the repo-bound WIF provider — build - # only, skip auth/deploy instead of failing red. - - name: Set deploy flags - id: flags - env: - HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - BASE_REPO: ${{ github.repository }} - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - if [ "$HEAD_REPO" = "$BASE_REPO" ]; then - echo "should_deploy=true" >> "$GITHUB_OUTPUT" - else - echo "should_deploy=false" >> "$GITHUB_OUTPUT" + - name: Download static site artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + + - name: Reject non-regular artifact entries + shell: bash + run: | + test -d dist + if find dist \( -type l -o -type b -o -type c -o -type p -o -type s \) -print -quit | grep -q .; then + echo "Static artifact contains a non-regular entry" >&2 + exit 1 fi - else - echo "should_deploy=true" >> "$GITHUB_OUTPUT" - fi - - # node22 + pnpm (corepack) come prebaked in the ci-runner image; the - # node_version input is no longer consulted (all sites are on 22). - - - name: Install dependencies - run: pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} - - - name: Type check - if: inputs.typecheck - run: pnpm tsc --noEmit - - - name: Lint - if: inputs.lint - run: pnpm lint - - - name: Build - shell: bash - run: | - if [ -n "$BUILD_ENV_VARS" ]; then - while IFS= read -r line; do - [ -n "$line" ] && export "$line" - done <<< "$BUILD_ENV_VARS" - fi - pnpm build - env: - BUILD_ENV_VARS: ${{ inputs.build_env }} - - - name: Authenticate to Google Cloud - if: steps.flags.outputs.should_deploy == 'true' - id: auth - uses: google-github-actions/auth@v3 - with: - project_id: ${{ env.PROJECT_ID }} - workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' - service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' - - - name: Set up Cloud SDK - if: steps.flags.outputs.should_deploy == 'true' - uses: google-github-actions/setup-gcloud@v2 - with: - skip_install: true # gcloud prebaked in ci-runner image - - # Warm gcloud's SQLite credential/token caches with a single serial call - # before the parallel rsync. On ephemeral runners ~/.config/gcloud is - # fresh every run, so the first gcloud invocation performs a schema - # migration (ALTER TABLE ... ADD COLUMN id_token / regional_access_ - # boundary*). `gcloud storage rsync` fans out into worker processes that - # otherwise race that migration and the token cache writes, failing - # upload tasks with "duplicate column name: ..." / "database is locked" - # and leaving the site partially deployed (2026-07-09, admin dashboard). - # Retry with backoff: the WIF token exchange re-fetches the GitHub OIDC - # subject token on refresh, and GitHub's Envoy-fronted token endpoint - # intermittently rejects under load ("upstream connect error ... reset - # reason: overflow" → "Unable to retrieve Identity Pool subject token", - # seen 2026-07-09 on run 29019157677). Better to absorb that here than - # fail the deploy — this step also doubles as the auth canary before - # the parallel rsync. - - name: Warm gcloud credential cache - if: steps.flags.outputs.should_deploy == 'true' - run: | - for i in 1 2 3 4 5; do - gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 - echo "gcloud warm-up attempt ${i}/5 failed; retrying in $((i * 10))s" - sleep $((i * 10)) - done - echo "gcloud warm-up failed after 5 attempts" >&2 - exit 1 - - - name: Deploy to production - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - env: - WEBSITE: ${{ inputs.website }} - run: | - gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ - ./dist "gs://${GCS_BUCKET}/${WEBSITE}" - - - name: Deploy preview - if: steps.flags.outputs.should_deploy == 'true' && github.event_name == 'pull_request' - env: - WEBSITE_SLUG: ${{ inputs.website_slug }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ - ./dist "gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" - - - name: Comment preview URL - if: steps.flags.outputs.should_deploy == 'true' && github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const slug = '${{ inputs.website_slug }}'; - const pr = context.payload.pull_request; - const previewUrl = `https://${slug}-pr${pr.number}.dev.cellarnode.com`; - const body = `## Preview Deployment\n\nPreview available at: ${previewUrl}`; - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - }); - - const botComment = comments.find(c => - c.user.type === 'Bot' && c.body.includes('Preview Deployment') - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - await github.rest.issues.createComment({ + + - name: Revalidate preview authorization + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + env: + ACTOR: ${{ github.actor }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + with: + github-token: ${{ github.token }} + script: *preview-authorization + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + project_id: ${{ env.PROJECT_ID }} + workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' + service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + with: + skip_install: true + + - name: Warm gcloud credential cache + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + run: | + for i in 1 2 3 4 5; do + gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 + echo "gcloud warm-up attempt ${i}/5 failed; retrying in $((i * 10))s" + sleep $((i * 10)) + done + echo "gcloud warm-up failed after 5 attempts" >&2 + exit 1 + + - name: Deploy preview + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + CLOUDSDK_STORAGE_PROCESS_COUNT: '1' + CLOUDSDK_STORAGE_THREAD_COUNT: '1' + WEBSITE_SLUG: ${{ inputs.website_slug }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ + ./dist "gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" + + - name: Comment preview URL + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + env: + WEBSITE_SLUG: ${{ inputs.website_slug }} + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const slug = process.env.WEBSITE_SLUG; + const prNumber = Number(process.env.PR_NUMBER); + const previewUrl = `https://${slug}-pr${prNumber}.dev.cellarnode.com`; + const marker = ''; + const body = `${marker}\n## Preview Deployment\n\nPreview available at: ${previewUrl}`; + + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, - issue_number: pr.number, - body: body + issue_number: prNumber, + per_page: 100, }); - } + + const botComment = comments.find(comment => + comment.user?.login === 'github-actions[bot]' && + ((comment.body ?? '').includes(marker) || (comment.body ?? '').includes('## Preview Deployment')) + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } cleanup: - if: github.event_name == 'pull_request' && github.event.action == 'closed' + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted-k8s container: - image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner:latest - + image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 permissions: contents: read id-token: write steps: - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 - with: - project_id: ${{ env.PROJECT_ID }} - workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' - service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + project_id: ${{ env.PROJECT_ID }} + workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' + service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 - with: - skip_install: true # gcloud prebaked in ci-runner image + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + with: + skip_install: true - - name: Delete preview - run: | - gcloud storage rm --recursive gs://${{ env.GCS_BUCKET }}/preview/${{ inputs.website_slug }}/pr${{ github.event.pull_request.number }} || true - - # --- Discord thread lifecycle --- + - name: Delete preview + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + WEBSITE_SLUG: ${{ inputs.website_slug }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + PREVIEW_PREFIX="gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" + PREVIEW_OBJECT=$(gcloud storage objects list "${PREVIEW_PREFIX}/**" --limit=1 --format='value(name)') + if [ -n "$PREVIEW_OBJECT" ]; then + gcloud storage rm --recursive "$PREVIEW_PREFIX" + fi discord-thread-open: - if: github.event_name == 'pull_request' && github.event.action == 'opened' + needs: preview-policy + if: >- + needs.preview-policy.outputs.trusted == 'true' && + github.event.action == 'opened' + permissions: + pull-requests: write uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -256,23 +774,28 @@ jobs: tags: ${{ inputs.discord_tags }} discord-build-update: - needs: [build-deploy, discord-thread-open] + needs: [preview-policy, build-preview, deploy-preview, discord-thread-open] if: >- always() && - github.event_name == 'pull_request' && - github.event.action != 'closed' && - needs.build-deploy.result != 'cancelled' + needs.preview-policy.outputs.trusted == 'true' && + needs.build-preview.result != 'cancelled' + permissions: + pull-requests: write uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} with: event: thread_update - status: ${{ needs.build-deploy.result }} - # Fork PRs skip the preview deploy — don't post a dead link - preview_url: ${{ needs.build-deploy.outputs.deployed == 'true' && format('https://{0}-pr{1}.dev.cellarnode.com', inputs.website_slug, github.event.pull_request.number) || '' }} + status: ${{ needs.build-preview.result == 'success' && (needs.deploy-preview.result == 'skipped' && 'success' || needs.deploy-preview.result) || needs.build-preview.result }} + preview_url: ${{ needs.deploy-preview.result == 'success' && format('https://{0}-pr{1}.dev.cellarnode.com', inputs.website_slug, github.event.pull_request.number) || '' }} discord-thread-close: - if: github.event_name == 'pull_request' && github.event.action == 'closed' + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + pull-requests: write uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -281,12 +804,15 @@ jobs: status: ${{ github.event.pull_request.merged && 'merged' || 'closed' }} discord-deploy: - needs: build-deploy + needs: [build-production, deploy-production] if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + pull-requests: read uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} with: event: deploy - status: ${{ needs.build-deploy.result }} + status: ${{ needs.build-production.result == 'success' && needs.deploy-production.result || needs.build-production.result }} tags: ${{ inputs.discord_tags }} diff --git a/.github/workflows/discord-notify.yaml b/.github/workflows/discord-notify.yaml index 939ecc6..4c46d9a 100644 --- a/.github/workflows/discord-notify.yaml +++ b/.github/workflows/discord-notify.yaml @@ -82,6 +82,26 @@ jobs: // --- Metadata stored as a hidden PR comment --- const META_TAG = '$/s); + if (!match) return null; + + try { + const metadata = JSON.parse(match[1]); + if (!metadata || Array.isArray(metadata) || typeof metadata !== 'object') return null; + + const entries = Object.entries(metadata); + if (entries.some(([key, value]) => !allowedMetadataKeys.has(key) || typeof value !== 'string' || !discordSnowflake.test(value))) { + return null; + } + return metadata; + } catch { + return null; + } + } async function getMetadata() { const pr = context.payload.pull_request; @@ -95,12 +115,9 @@ jobs: }); for (const c of comments) { - if (c.body?.includes(META_TAG)) { - try { - const json = c.body.split(META_TAG)[1].split('-->')[0].trim(); - return { commentId: c.id, ...JSON.parse(json) }; - } catch { /* ignore malformed */ } - } + if (c.user?.login !== 'github-actions[bot]') continue; + const metadata = parseMetadata(c.body ?? ''); + if (metadata) return { ...metadata, commentId: c.id }; } return {}; } @@ -363,11 +380,10 @@ jobs: per_page: 100 }); for (const c of comments) { - if (c.body?.includes(META_TAG)) { - try { - const json = c.body.split(META_TAG)[1].split('-->')[0].trim(); - threadId = JSON.parse(json).threadId; - } catch {} + if (c.user?.login !== 'github-actions[bot]') continue; + const metadata = parseMetadata(c.body ?? ''); + if (metadata?.threadId) { + threadId = metadata.threadId; break; } } diff --git a/.github/workflows/validate-static-deploy.yaml b/.github/workflows/validate-static-deploy.yaml new file mode 100644 index 0000000..761773d --- /dev/null +++ b/.github/workflows/validate-static-deploy.yaml @@ -0,0 +1,41 @@ +name: Validate shared static deploy workflow + +on: + pull_request: + paths: + - .github/tests/deploy-static-lock-validator.test.rb + - .github/tests/deploy-static-job-boundaries.test.rb + - .github/tests/discord-metadata-boundary.test.rb + - .github/workflows/discord-notify.yaml + - .github/workflows/deploy-static-website.yaml + - .github/workflows/validate-static-deploy.yaml + push: + branches: [main] + paths: + - .github/tests/deploy-static-lock-validator.test.rb + - .github/tests/deploy-static-job-boundaries.test.rb + - .github/tests/discord-metadata-boundary.test.rb + - .github/workflows/discord-notify.yaml + - .github/workflows/deploy-static-website.yaml + - .github/workflows/validate-static-deploy.yaml + +permissions: + contents: read + +jobs: + lock-validator: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + + - name: Test lock validator fixtures + run: ruby .github/tests/deploy-static-lock-validator.test.rb + + - name: Test static deploy job boundaries + run: ruby .github/tests/deploy-static-job-boundaries.test.rb + + - name: Test Discord metadata boundary + run: ruby .github/tests/discord-metadata-boundary.test.rb