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.
This commit is contained in:
Adam Moussa 2026-09-11 15:17:36 -04:00 • committed by GitHub
parent 7716b4afc8
commit f23c60ccc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 245 additions and 55 deletions

View file

@ -1,13 +1,14 @@
name: Validate and deploy name: Deploy dev content
# Dev content CD through Terraform (SH-300). GitHub uploads an immutable # Dev content CD through Terraform (SH-300). GitHub uploads an immutable
# releases/<sha>-<run>-<attempt>/ prefix. Terraform owns the pointer, origin # releases/<sha>-<run>-<attempt>/ prefix. Terraform owns the pointer, origin
# group, and invalidation. Push-to-dev stays off until # group, and invalidation. Push-to-dev stays off until
# vars.TERRAFORM_CONTENT_CD_ENABLED is the string true. # 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: on:
pull_request:
branches: [dev]
push: push:
branches: [dev] branches: [dev]
paths-ignore: paths-ignore:
@ -18,61 +19,12 @@ permissions:
contents: read contents: read
jobs: 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: deploy-dev:
name: Deploy shoc-frontend-new-dev through Terraform name: Deploy shoc-frontend-new-dev through Terraform
if: > if: >
(github.event_name == 'push' && github.ref == 'refs/heads/dev' && (github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') || vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') ||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
needs: validate
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 180 timeout-minutes: 180
permissions: permissions:

View file

@ -4,6 +4,7 @@
Accepts exactly: Accepts exactly:
- an update of the release pointer (content, plus computed etag/version_id) - an update of the release pointer (content, plus computed etag/version_id)
- an update of the distribution with only origin[*].origin_path changed - 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 - exactly one action invocation for the CloudFront invalidation
after origin_path values must match the expected labels. before origin_path after origin_path values must match the expected labels. before origin_path
@ -52,6 +53,11 @@ DISTRIBUTION_UNKNOWN_ATTRIBUTES = frozenset(
"in_progress_validation_batches", "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} REDIRECT_STATUSES = {301, 302, 303, 307, 308}
UrlOpen = Callable[..., Any] UrlOpen = Callable[..., Any]
@ -302,10 +308,16 @@ def _validate_pointer(
return violations 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: 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"} return _origin_fields_for_compare(before) != _origin_fields_for_compare(after)
after_rest = {key: value for key, value in after.items() if key != "origin_path"}
return before_rest != after_rest
def _validate_distribution( def _validate_distribution(

View file

@ -291,12 +291,29 @@ def test_deploy_workflow_confirms_prefix_with_head_object() -> list[str]:
) )
if "aws s3 ls" in workflow: if "aws s3 ls" in workflow:
failures.append("deploy.yml must not list the prefix with aws s3 ls") 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 return failures
def main() -> int: def main() -> int:
cases = [ cases = [
("version-only", run_case("version-only.json"), 0), ("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-label", run_case("wrong-label.json"), 1),
("wrong-before", run_case("wrong-before.json"), 1), ("wrong-before", run_case("wrong-before.json"), 1),
("extra-origin-change", run_case("extra-origin-change.json"), 1), ("extra-origin-change", run_case("extra-origin-change.json"), 1),

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}