From f23c60ccc20fd3dfde98a44a79a92bd53c1399ea Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:17:36 -0400 Subject: [PATCH] fix(ci): skip duplicate verify on content CD and ignore origin timeout drift (SH-300) (#183) * fix(terraform): ignore origin response_completion_timeout in the release plan guard (SH-300) AWS returns 0 when the timeout is unset. The provider writes null on origin_path updates, so the first real CD plan failed closed. * fix(ci): drop duplicate verify from the content CD workflow (SH-300) Frontend checks already runs verify on PRs and pushes. Removing the validate job also requires dropping needs: validate so dispatch can run. * fix(terraform): equate origin timeout 0 and null only (SH-300) Numeric timeout changes still fail closed. Rename the filter so it is not read as an after_unknown allowlist. --- .github/workflows/deploy.yml | 56 +--------- scripts/check-terraform-release-plan.py | 18 ++- scripts/test-terraform-release-plan-check.py | 17 +++ .../origin-timeout-change.json | 104 +++++++++++++++++ .../origin-timeout-normalization.json | 105 ++++++++++++++++++ 5 files changed, 245 insertions(+), 55 deletions(-) create mode 100644 scripts/testdata/terraform-release-plans/origin-timeout-change.json create mode 100644 scripts/testdata/terraform-release-plans/origin-timeout-normalization.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c276aa2c..a388a043 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,13 +1,14 @@ -name: Validate and deploy +name: Deploy dev content # Dev content CD through Terraform (SH-300). GitHub uploads an immutable # releases/--/ prefix. Terraform owns the pointer, origin # group, and invalidation. Push-to-dev stays off until # vars.TERRAFORM_CONTENT_CD_ENABLED is the string true. +# +# Quality gates live in Frontend checks (`ci.yaml`). This workflow does not +# re-run those gates on pull requests, pushes, or workflow_dispatch. on: - pull_request: - branches: [dev] push: branches: [dev] paths-ignore: @@ -18,61 +19,12 @@ permissions: contents: read jobs: - validate: - name: Validate production build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24" - cache: npm - - name: Set up Terraform - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - with: - terraform_version: "1.16.0" - terraform_wrapper: false - - name: Install actionlint - env: - ACTIONLINT_VERSION: "1.7.12" - ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 - run: | - set -euo pipefail - curl -fsSL -o actionlint.tar.gz \ - "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c - - tar -xzf actionlint.tar.gz actionlint - sudo mv actionlint /usr/local/bin/actionlint - - name: Quality gates - run: npm ci && npm run verify - env: - GOVERNANCE_BASE: ${{ github.event.pull_request.base.sha || 'origin/dev' }} - - name: Build with pinned API URL - env: - VITE_API_URL: https://api.dev.seahaven.com/api - VITE_APP_COMMIT_SHA: ${{ github.sha }} - run: | - set -euo pipefail - npm run build - if grep -Rq "api.staging.seahaven.com" dist/; then - echo "::error::Built assets contain the staging API URL." >&2 - exit 1 - fi - if grep -Rq "localhost:5141" dist/; then - echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2 - exit 1 - fi - grep -Rq "api.dev.seahaven.com" dist/ - deploy-dev: name: Deploy shoc-frontend-new-dev through Terraform if: > (github.event_name == 'push' && github.ref == 'refs/heads/dev' && vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') - needs: validate runs-on: ubuntu-latest timeout-minutes: 180 permissions: diff --git a/scripts/check-terraform-release-plan.py b/scripts/check-terraform-release-plan.py index 1d713891..03ae590f 100755 --- a/scripts/check-terraform-release-plan.py +++ b/scripts/check-terraform-release-plan.py @@ -4,6 +4,7 @@ Accepts exactly: - an update of the release pointer (content, plus computed etag/version_id) - an update of the distribution with only origin[*].origin_path changed + (response_completion_timeout 0, null, and a missing key are equivalent) - exactly one action invocation for the CloudFront invalidation after origin_path values must match the expected labels. before origin_path @@ -52,6 +53,11 @@ DISTRIBUTION_UNKNOWN_ATTRIBUTES = frozenset( "in_progress_validation_batches", } ) +# Not an after_unknown allowlist. AWS returns 0 when the timeout is unset; +# the provider writes null on origin_path updates. Treat 0, null, and a +# missing key as the same. Any other value still fails closed. +ORIGIN_RESPONSE_COMPLETION_TIMEOUT = "response_completion_timeout" +ORIGIN_TIMEOUT_UNSET = frozenset({0, None}) REDIRECT_STATUSES = {301, 302, 303, 307, 308} UrlOpen = Callable[..., Any] @@ -302,10 +308,16 @@ def _validate_pointer( return violations +def _origin_fields_for_compare(origin: dict[str, Any]) -> dict[str, Any]: + rest = {key: value for key, value in origin.items() if key != "origin_path"} + timeout = rest.get(ORIGIN_RESPONSE_COMPLETION_TIMEOUT) + if timeout in ORIGIN_TIMEOUT_UNSET: + rest.pop(ORIGIN_RESPONSE_COMPLETION_TIMEOUT, None) + return rest + + def _origin_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool: - before_rest = {key: value for key, value in before.items() if key != "origin_path"} - after_rest = {key: value for key, value in after.items() if key != "origin_path"} - return before_rest != after_rest + return _origin_fields_for_compare(before) != _origin_fields_for_compare(after) def _validate_distribution( diff --git a/scripts/test-terraform-release-plan-check.py b/scripts/test-terraform-release-plan-check.py index 4cfaac79..d050701e 100755 --- a/scripts/test-terraform-release-plan-check.py +++ b/scripts/test-terraform-release-plan-check.py @@ -291,12 +291,29 @@ def test_deploy_workflow_confirms_prefix_with_head_object() -> list[str]: ) if "aws s3 ls" in workflow: failures.append("deploy.yml must not list the prefix with aws s3 ls") + if any( + line.lstrip().startswith("run:") and "npm run verify" in line + for line in workflow.splitlines() + ): + failures.append( + "deploy.yml must not re-run npm run verify; Frontend checks owns that gate" + ) + if any(line.lstrip().startswith("pull_request:") for line in workflow.splitlines()): + failures.append( + "deploy.yml must not run on pull_request; Frontend checks owns PR verify" + ) return failures def main() -> int: cases = [ ("version-only", run_case("version-only.json"), 0), + ( + "origin-timeout-normalization", + run_case("origin-timeout-normalization.json"), + 0, + ), + ("origin-timeout-change", run_case("origin-timeout-change.json"), 1), ("wrong-label", run_case("wrong-label.json"), 1), ("wrong-before", run_case("wrong-before.json"), 1), ("extra-origin-change", run_case("extra-origin-change.json"), 1), diff --git a/scripts/testdata/terraform-release-plans/origin-timeout-change.json b/scripts/testdata/terraform-release-plans/origin-timeout-change.json new file mode 100644 index 00000000..a5f42591 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/origin-timeout-change.json @@ -0,0 +1,104 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1", + "response_completion_timeout": 10 + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1", + "response_completion_timeout": 60 + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/origin-timeout-normalization.json b/scripts/testdata/terraform-release-plans/origin-timeout-normalization.json new file mode 100644 index 00000000..e70aa560 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/origin-timeout-normalization.json @@ -0,0 +1,105 @@ +{ + "resource_changes": [ + { + "address": "module.environment_owned.aws_iam_role_policy.github_deploy", + "mode": "managed", + "type": "aws_iam_role_policy", + "change": { + "actions": ["no-op"], + "before": { + "name": "policy" + }, + "after": { + "name": "policy" + } + } + }, + { + "address": "module.environment_owned.aws_s3_object.release_pointer", + "mode": "managed", + "type": "aws_s3_object", + "change": { + "actions": ["update"], + "before": { + "content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}", + "key": ".release/current" + }, + "after": { + "content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}", + "key": ".release/current" + }, + "after_unknown": { + "etag": true, + "version_id": true + } + } + }, + { + "address": "module.environment_owned.aws_cloudfront_distribution.site", + "mode": "managed", + "type": "aws_cloudfront_distribution", + "change": { + "actions": ["update"], + "before": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1", + "response_completion_timeout": 0 + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/0000000000000000000000000000000000000000-1-1", + "response_completion_timeout": 0 + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after": { + "origin": [ + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1", + "origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1", + "response_completion_timeout": null + }, + { + "connection_attempts": 3, + "connection_timeout": 10, + "domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com", + "origin_access_control_id": "E30VSIK87N8H64", + "origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous", + "origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + } + ], + "enabled": true, + "comment": "SeaHaven SHOC frontend (dev)" + }, + "after_unknown": { + "etag": true, + "last_modified_time": true, + "status": true, + "in_progress_validation_batches": true + } + } + } + ], + "action_invocations": [ + { + "address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release", + "type": "aws_cloudfront_create_invalidation" + } + ] +}