Skip to content

Add a static blog site#1605

Merged
ytallo merged 8 commits intomainfrom
add/blog
May 6, 2026
Merged

Add a static blog site#1605
ytallo merged 8 commits intomainfrom
add/blog

Conversation

@anthonyiscoding
Copy link
Copy Markdown
Contributor

@anthonyiscoding anthonyiscoding commented May 6, 2026

What

New static blog site for iii

Summary by CodeRabbit

  • New Features

    • Launched a new blog section with posts, index, and RSS; added theme support and cookie/analytics consent.
  • Infrastructure

    • Deployment updated to build and publish blog assets separately; added blog-aware redirects/rewrites and sitemap entries.
  • Documentation

    • Added a comprehensive local development, build, test, and contribution guide for the blog.
  • Tests

    • Added build and redirect tests plus unit tests verifying blog post handling.

…ests

Adds a new blog/ workspace package built with Astro 6, configured with
base: '/blog' and directory output so each post emits as
/blog/<slug>/index.html, ready to be served as a subpath of iii.dev
without any path rewrites.

Includes a typed content collection schema, a sample post, RSS feed,
and node:test build-output assertions that catch base-path regressions
before they ship.

Infrastructure (CloudFront /blog* behavior, S3 sync, SPA-fallback fix,
sitemap merge) is intentionally deferred to a follow-up step.
Astro emits the blog with trailingSlash: 'always' and build.format:
'directory', so canonical URLs are /blog/<slug>/ backed by
blog/<slug>/index.html keys in S3. CloudFront's default_root_object
only handles the apex, and the existing SPA fallback would rewrite
/blog/<slug> to /index.html, so /blog/* needs explicit routing in
the viewer-request function.

Adds a /blog/* block before the SPA fallback that:
- 301s /blog and /blog/<extensionless>/ to the canonical trailing-slash
  form (preserves querystring, uses request host so preview deploys
  redirect to themselves)
- Rewrites /blog/<path>/ to /blog/<path>/index.html so S3 serves the
  directory index
- Passes /blog/<file.ext> through unchanged for assets and RSS
- Lets /blogfoo and similar prefixes fall through to the existing SPA
  logic (boundary-safe via the /blog/ prefix check)

12 new test cases in redirects.test.js cover each branch plus
querystring preservation.
Extends the existing website deploy workflow to also build the Astro
blog and sync blog/dist/ to s3://<bucket>/blog/. The blog and website
share the same S3 bucket and CloudFront distribution, so:

- Trigger paths now include blog/**.
- The website syncs gain --exclude "blog/*" so their --delete flag
  doesn't wipe blog output between sync steps.
- Blog deploy mirrors the website's two-pass cache-control split:
  hashed assets under _astro/ get max-age=31536000,immutable; *.html
  and *.xml get max-age=0,must-revalidate so new posts surface
  immediately after the /* invalidation.

The blog README now documents the deploy flow and links to the
CloudFront routing tests as the source of truth for /blog/* behavior.
Adds website/scripts/blog-posts.ts — a tiny dependency-free frontmatter
reader for blog/src/content/blog/*.{md,mdx} — and wires it into the
existing generate-sitemap.ts so /blog/ and every non-draft /blog/<slug>/
URL appears in iii.dev/sitemap.xml. lastmod uses updatedDate ?? pubDate
so updated posts surface promptly in search.

Six new node:test cases cover frontmatter parsing, draft handling,
sort order, missing dirs, and a smoke test against the seeded
hello-world post.

Also drops @astrojs/sitemap from pnpm-lock.yaml (was removed from
blog/package.json earlier due to a pnpm hoisting bug in 3.7.2).
Mirrors the analytics + consent stack from website/index.html so the
blog tracks page views the same way the marketing site does and
honors the same consent decision. Because /blog and / share an origin
the localStorage 'iii_cookie_consent' key carries across both — a
visitor who accepts (or declines) on either entry point won't see
the banner on the other.

Adds two Astro components used by BaseLayout:

- AnalyticsHead.astro — GTM container script + Common Room loader
  (defines window.iiiLoadCommonRoomSignals / iiiNotifyCommonRoomEmail
  but only auto-fetches the Common Room signals.js after consent).
- CookieBanner.astro — GTM <noscript> iframe, banner DOM/CSS, and the
  accept/decline script that calls iiiLoadCommonRoomSignals on accept.

Two new build-output tests assert that both the index and post pages
ship the GTM container ID, the noscript iframe, both Common Room
globals, the signals URL, the banner DOM, and the shared storage key
— so any drift from the website snippet fails CI.

CSP already allows googletagmanager.com and *.cr-relay.com on the
default S3 cache behavior, so no Terraform change is needed.
…rk mode

Brings the blog visual identity in line with the marketing site:

- Adopts the website's design tokens (warm paper bg, Chivo Mono,
  orange #ff5a1f accent, full dark-mode palette) by porting the
  :root variables verbatim.
- Adds ThemeInit.astro — the same inline pre-paint theme detector the
  website uses, reading the shared 'iii_theme' localStorage key. A
  user's dark/light choice now carries between iii.dev and /blog with
  no flash of unstyled palette.
- References /fonts/ChivoMono-*.ttf directly so the blog reuses the
  fonts already deployed at the website root (single source of truth,
  same S3 bucket).
- New Logo.astro and Footer.astro components reproduce the iii
  six-rect SVG and the website's three-column footer (developers /
  contact / brand) plus the © Motia LLC bottom row.
- BaseLayout.astro wraps content in the website's bordered .sheet
  with a sticky bordered .nav; main content stays a comfortable
  760px reading column.
- Post index uses the website's underlined-on-hover orange-accent
  link treatment for consistent affordances.

Two new build tests pin down the parity: one asserts the iii_theme
key + pre-paint dark-mode application, one asserts the iii logo SVG
renders in the header. Updated the URL allowlist for the new footer
links (/docs/quickstart, /fonts/*).
Three small theme-parity fixes:

- Logo: the website renders the iii six-rect mark with all six rects
  filled in --ink in both navbar and footer; --accent (orange) is
  reserved for highlight text only. The blog was painting the middle
  column orange, which made the icon look like a different logo.
  Both Logo placements now use var(--ink) consistently.
- Navbar: drop the 'iii / blog' wordmark — the website navbar shows
  only the icon, so the wordmark was extra noise that diverged from
  the parent site.
- CookieBanner: leftover --fg / --border tokens from the pre-theme
  scaffold no longer exist after the theme rewrite, which left the
  Accept button rendering as transparent on transparent. Switched
  the banner to the live token set (--ink, --paper, --rule, --bg).
@anthonyiscoding anthonyiscoding requested review from rohitg00 and ytallo and removed request for ytallo May 6, 2026 15:27
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
iii-website Ready Ready Preview, Comment May 6, 2026 3:41pm

Request Review

@anthonyiscoding anthonyiscoding requested a review from ytallo May 6, 2026 15:27
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Caution

Review failed

Failed to post review comments

📝 Walkthrough

Walkthrough

Introduces a complete Astro-based blog under blog/ (content, layouts, components, styles, tests), adds build/deploy steps to CI to separately build and sync blog artifacts, updates CloudFront functions and tests to canonicalize blog URLs, and integrates blog posts into the website sitemap and scripts.

Changes

Blog Feature

Layer / File(s) Summary
Project Setup
blog/package.json, blog/tsconfig.json, blog/.gitignore, blog/astro.config.mjs, pnpm-workspace.yaml
New Astro blog package added to workspace with dependencies, TS config, gitignore, base /blog config, and workspace registration.
Content Schema & Sample
blog/src/content.config.ts, blog/src/content/blog/hello-world.md
Defines blog content collection schema (title, description, pubDate, optional updatedDate/draft/tags) and adds a sample hello-world post.
Styling & Theme Init
blog/src/styles/global.css, blog/src/components/ThemeInit.astro
Global CSS with design tokens, fonts, responsive rules; ThemeInit reads iii_theme from localStorage and applies theme before paint.
Layouts & UI Components
blog/src/layouts/BaseLayout.astro, blog/src/layouts/BlogPost.astro, blog/src/components/Logo.astro, blog/src/components/Footer.astro, blog/src/components/FormattedDate.astro
Base layout and blog-post layout plus logo, footer, and date formatting components used across blog pages.
Analytics & Consent
blog/src/components/AnalyticsHead.astro, blog/src/components/CookieBanner.astro
GTM integration and lazy Common Room signals loader; cookie-consent banner persisting choice in localStorage under iii_cookie_consent.
Pages & Feeds
blog/src/pages/index.astro, blog/src/pages/[...slug].astro, blog/src/pages/rss.xml.ts
Index lists published posts sorted by pubDate; dynamic slug route generates post pages; RSS endpoint exposes published posts.
Build Tests
blog/tests/build.test.ts
Tests that verify generated index/post HTML, RSS presence, analytics/consent, theme initialization before paint, logo rendering, and that internal links/assets are scoped under /blog/ or allowlist.
Website Integration (scripts & sitemap)
website/scripts/blog-posts.ts, website/scripts/blog-posts.test.ts, website/scripts/generate-sitemap.ts, website/sitemap.xml
Adds readBlogPosts() to parse frontmatter and export BlogPost interface; unit tests for parser; sitemap generator made async and includes /blog/ and per-post URLs with lastmod.
Deployment & CI
.github/workflows/deploy-website.yml, .github/workflows/ci.yml, .github/workflows/license-check.yml
Deploy workflow extended to build blog and perform separate S3 syncs (long-cache blog static assets under /blog/ prefix; no-cache HTML/RSS under /blog/); CI and license-check workflows now ignore blog/** in path filters.
CloudFront Routing & Tests
infra/terraform/website/cloudfront_functions/redirects.js, infra/terraform/website/cloudfront_functions/redirects.test.js
Adds blog canonicalization logic: /blog/blog/ redirect, directory → index.html rewrites for /blog/*, pass-through for files/extensions and hashed assets, querystring preservation, and host-aware redirect tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • iii-hq/iii#1579: Modifies the same CloudFront redirects handler and tests; overlapping routing changes.
  • iii-hq/iii#1486: Also touches CloudFront function routing/canonicalization logic.
  • iii-hq/iii#1470: Related deployment workflow and CloudFront/asset sync changes.

Suggested reviewers

  • ytallo
  • sergiofilhowz
  • andersonleal

"I hopped across the repo bright and spry,
Built pages and feeds beneath the sky,
Theme set quick before the first paint,
Cookies, GTM, and redirects quaint,
The blog now hums — go take a look and try." 🐰✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description provides only a brief 'What' section but is missing the 'Why' and 'Notes' sections from the template, and lacks the required Apache 2 licensing checkbox. Add 'Why' and 'Notes' sections to explain motivation and important context, and include the Apache 2 licensing checkbox to comply with the repository template.
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add a static blog site' directly and clearly describes the main change: introducing a new static blog. It is concise and specific.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add/blog

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Terraform plan — infra/terraform/website

Click to expand
data.aws_caller_identity.current: Reading...
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms]
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
data.aws_route53_zone.iii_dev: Reading...
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::600627348446:oidc-provider/token.actions.githubusercontent.com]
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_cloudfront_response_headers_policy.site: Refreshing state... [id=033b1d66-8d53-48a1-a817-8280d8a60114]
data.aws_caller_identity.current: Read complete after 0s [id=600627348446]
data.aws_iam_policy_document.github_tf_plan_trust: Reading...
data.aws_iam_policy_document.github_trust: Reading...
data.aws_iam_policy_document.github_tf_plan_trust: Read complete after 0s [id=2702237784]
data.aws_iam_policy_document.github_trust: Read complete after 0s [id=932063200]
aws_iam_role.github_tf_plan: Refreshing state... [id=iii-infra-github-tf-plan]
aws_iam_role.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy]
aws_sns_topic_subscription.email: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms:489ae5f2-92df-4071-8c35-5d600930b024]
aws_cloudwatch_metric_alarm.acm_days_to_expiry: Refreshing state... [id=iii-website-prod-acm-days-to-expiry]
aws_iam_role_policy_attachment.github_tf_plan_readonly: Refreshing state... [id=iii-infra-github-tf-plan-20260414113923643200000001]
aws_s3_bucket_server_side_encryption_configuration.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_versioning.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_public_access_block.site: Refreshing state... [id=iii-website-prod-us-east-1]
data.aws_route53_zone.iii_dev: Read complete after 1s [id=Z05516132AI1ZGB3NLC6D]
aws_route53_record.cert_validation["iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__7853369f3aea55a5d242d7c68506980e.iii.dev._CNAME]
aws_route53_record.cert_validation["iii-preview.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__0e43dc21d86b26a03742fb15aa5b4558.iii-preview.iii.dev._CNAME]
aws_route53_record.cert_validation["www.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__2bb3298c0bcca2494fce5e36c3d1427d.www.iii.dev._CNAME]
aws_acm_certificate_validation.site: Refreshing state... [id=2026-04-14 00:00:29.305 +0000 UTC]
aws_cloudfront_distribution.site: Refreshing state... [id=E3N35RPQE4YVFQ]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
aws_route53_record.preview_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
data.aws_iam_policy_document.github_deploy_website: Reading...
data.aws_iam_policy_document.site_bucket: Reading...
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
data.aws_iam_policy_document.github_deploy_website: Read complete after 0s [id=1235650417]
data.aws_iam_policy_document.site_bucket: Read complete after 0s [id=2990320740]
aws_iam_policy.github_deploy_website: Refreshing state... [id=arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
aws_s3_bucket_policy.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_iam_role_policy_attachment.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy-20260414000337121000000003]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
  - destroy

Terraform will perform the following actions:

  # aws_cloudfront_function.redirects will be updated in-place
  ~ resource "aws_cloudfront_function" "redirects" {
      ~ code                         = <<-EOT
            // Viewer-request handler for the default (S3) behavior. Tested in redirects.test.js.
            
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
          -   };
          +   }
            }
            
            // CloudFront Functions deliver request.querystring as
            //   { key: { value: string, multiValue?: [{ value: string }, ...] } }
            // where repeated params spill into multiValue. We re-encode and rejoin so the
            // host-redirect path below preserves the original query (otherwise `?a=1&a=2`
            // would silently drop on the 301).
            function serializeQuerystring(qs) {
          -   if (!qs) return '';
          -   var parts = [];
          +   if (!qs) return ''
          +   var parts = []
              for (var key in qs) {
          -     if (!Object.prototype.hasOwnProperty.call(qs, key)) continue;
          -     var entry = qs[key];
          -     if (!entry) continue;
          -     var encodedKey = encodeURIComponent(key);
          -     var primary = entry.value == null ? '' : entry.value;
          -     parts.push(encodedKey + '=' + encodeURIComponent(primary));
          +     if (!Object.prototype.hasOwnProperty.call(qs, key)) continue
          +     var entry = qs[key]
          +     if (!entry) continue
          +     var encodedKey = encodeURIComponent(key)
          +     var primary = entry.value == null ? '' : entry.value
          +     parts.push(encodedKey + '=' + encodeURIComponent(primary))
                if (entry.multiValue && entry.multiValue.length) {
                  for (var i = 0; i < entry.multiValue.length; i++) {
          -         var extra = entry.multiValue[i];
          -         var extraValue = extra && extra.value != null ? extra.value : '';
          -         parts.push(encodedKey + '=' + encodeURIComponent(extraValue));
          +         var extra = entry.multiValue[i]
          +         var extraValue = extra && extra.value != null ? extra.value : ''
          +         parts.push(encodedKey + '=' + encodeURIComponent(extraValue))
                  }
                }
              }
          -   return parts.length ? '?' + parts.join('&') : '';
          +   return parts.length ? '?' + parts.join('&') : ''
            }
            
            // biome-ignore lint/correctness/noUnusedVariables: CloudFront Function entry point
            // biome-ignore lint/complexity/useOptionalChain: cloudfront-js-2.0 does NOT support optional chaining
            function handler(event) {
          -   var request = event.request;
          -   var uri = request.uri;
          -   var host =
          -     request.headers && request.headers.host
          -       ? request.headers.host.value
          -       : undefined;
          +   var request = event.request
          +   var uri = request.uri
          +   var host = request.headers && request.headers.host ? request.headers.host.value : undefined
            
              if (host === 'www.iii.dev') {
          -     return redirect(
          -       `https://iii.dev${uri}${serializeQuerystring(request.querystring)}`,
          -     );
          +     return redirect(`https://iii.dev${uri}${serializeQuerystring(request.querystring)}`)
              }
            
          -   if (uri.indexOf('/.well-known/') === 0) return request;
          +   if (uri.indexOf('/.well-known/') === 0) return request
            
              // Pretty URLs → matching *.html objects in S3 (Option A). Add a key when you
              // ship a new top-level page as `pagename.html`.
              var htmlPretty = {
                '/manifesto': '/manifesto.html',
          -   };
          -   var htmlTarget = htmlPretty[uri];
          +   }
          +   var htmlTarget = htmlPretty[uri]
              if (htmlTarget !== undefined) {
          -     request.uri = htmlTarget;
          -     return request;
          +     request.uri = htmlTarget
          +     return request
              }
            
          +   // /blog/* — Astro emits build.format: 'directory' with trailingSlash:
          +   // 'always', so canonical URLs are /blog/<slug>/. CloudFront's
          +   // default_root_object only applies to the apex, so we rewrite directory
          +   // URLs to .../index.html and 301 extensionless paths to the canonical
          +   // trailing-slash form. Must run before the SPA fallback so /blog/<slug>
          +   // doesn't get hijacked into /index.html.
          +   var redirectHost = host || 'iii.dev'
          +   if (uri === '/blog') {
          +     return redirect('https://' + redirectHost + '/blog/' + serializeQuerystring(request.querystring))
          +   }
          +   if (uri.indexOf('/blog/') === 0) {
          +     if (uri.charAt(uri.length - 1) === '/') {
          +       request.uri = uri + 'index.html'
          +       return request
          +     }
          +     var lastSlashB = uri.lastIndexOf('/')
          +     var lastSegmentB = uri.substring(lastSlashB + 1)
          +     if (lastSegmentB.indexOf('.') === -1) {
          +       return redirect('https://' + redirectHost + uri + '/' + serializeQuerystring(request.querystring))
          +     }
          +     return request
          +   }
          + 
              // SPA fallback: extensionless path not ending in /
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
          -     const lastSlash = uri.lastIndexOf('/');
          -     const lastSegment = uri.substring(lastSlash + 1);
          +     const lastSlash = uri.lastIndexOf('/')
          +     const lastSegment = uri.substring(lastSlash + 1)
                if (lastSegment.indexOf('.') === -1) {
          -       request.uri = '/index.html';
          -       return request;
          +       request.uri = '/index.html'
          +       return request
                }
              }
            
          -   return request;
          +   return request
            }
        EOT
        id                           = "iii-website-prod-redirects"
        name                         = "iii-website-prod-redirects"
        # (8 unchanged attributes hidden)
    }

  # aws_route53_record.apex_a[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "apex_a" {
      - fqdn                             = "iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_iii.dev_A" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "A" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.apex_aaaa[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "apex_aaaa" {
      - fqdn                             = "iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_iii.dev_AAAA" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "AAAA" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.www_a[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "www_a" {
      - fqdn                             = "www.iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_www.iii.dev_A" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "www.iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "A" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.www_aaaa[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "www_aaaa" {
      - fqdn                             = "www.iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "www.iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "AAAA" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_sns_topic_subscription.email will be created
  + resource "aws_sns_topic_subscription" "email" {
      + arn                             = (known after apply)
      + confirmation_timeout_in_minutes = 1
      + confirmation_was_authenticated  = (known after apply)
      + endpoint                        = "devops@motia.dev"
      + endpoint_auto_confirms          = false
      + filter_policy_scope             = (known after apply)
      + id                              = (known after apply)
      + owner_id                        = (known after apply)
      + pending_confirmation            = (known after apply)
      + protocol                        = "email"
      + raw_message_delivery            = false
      + topic_arn                       = "arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms"
    }

Plan: 1 to add, 1 to change, 4 to destroy.

The blog has its own build + tests via deploy-website.yml; mirror the
website/** paths-ignore entry so docs-style content changes under
blog/ don't trigger the full Rust/Node CI matrix or license scan.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Terraform plan — infra/terraform/website

Click to expand
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
aws_cloudfront_response_headers_policy.site: Refreshing state... [id=033b1d66-8d53-48a1-a817-8280d8a60114]
data.aws_caller_identity.current: Reading...
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms]
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
data.aws_route53_zone.iii_dev: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::600627348446:oidc-provider/token.actions.githubusercontent.com]
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
data.aws_caller_identity.current: Read complete after 0s [id=600627348446]
data.aws_iam_policy_document.github_trust: Reading...
data.aws_iam_policy_document.github_tf_plan_trust: Reading...
data.aws_iam_policy_document.github_tf_plan_trust: Read complete after 0s [id=2702237784]
data.aws_iam_policy_document.github_trust: Read complete after 0s [id=932063200]
aws_iam_role.github_tf_plan: Refreshing state... [id=iii-infra-github-tf-plan]
aws_iam_role.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy]
aws_sns_topic_subscription.email: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms:489ae5f2-92df-4071-8c35-5d600930b024]
aws_cloudwatch_metric_alarm.acm_days_to_expiry: Refreshing state... [id=iii-website-prod-acm-days-to-expiry]
aws_iam_role_policy_attachment.github_tf_plan_readonly: Refreshing state... [id=iii-infra-github-tf-plan-20260414113923643200000001]
data.aws_route53_zone.iii_dev: Read complete after 1s [id=Z05516132AI1ZGB3NLC6D]
aws_route53_record.cert_validation["iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__7853369f3aea55a5d242d7c68506980e.iii.dev._CNAME]
aws_route53_record.cert_validation["www.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__2bb3298c0bcca2494fce5e36c3d1427d.www.iii.dev._CNAME]
aws_route53_record.cert_validation["iii-preview.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__0e43dc21d86b26a03742fb15aa5b4558.iii-preview.iii.dev._CNAME]
aws_s3_bucket_public_access_block.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_server_side_encryption_configuration.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_versioning.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_acm_certificate_validation.site: Refreshing state... [id=2026-04-14 00:00:29.305 +0000 UTC]
aws_cloudfront_distribution.site: Refreshing state... [id=E3N35RPQE4YVFQ]
aws_route53_record.preview_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
data.aws_iam_policy_document.github_deploy_website: Reading...
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
data.aws_iam_policy_document.github_deploy_website: Read complete after 0s [id=1235650417]
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
data.aws_iam_policy_document.site_bucket: Reading...
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
aws_iam_policy.github_deploy_website: Refreshing state... [id=arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy]
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
data.aws_iam_policy_document.site_bucket: Read complete after 0s [id=2990320740]
aws_s3_bucket_policy.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_iam_role_policy_attachment.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy-20260414000337121000000003]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
  - destroy

Terraform will perform the following actions:

  # aws_cloudfront_function.redirects will be updated in-place
  ~ resource "aws_cloudfront_function" "redirects" {
      ~ code                         = <<-EOT
            // Viewer-request handler for the default (S3) behavior. Tested in redirects.test.js.
            
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
          -   };
          +   }
            }
            
            // CloudFront Functions deliver request.querystring as
            //   { key: { value: string, multiValue?: [{ value: string }, ...] } }
            // where repeated params spill into multiValue. We re-encode and rejoin so the
            // host-redirect path below preserves the original query (otherwise `?a=1&a=2`
            // would silently drop on the 301).
            function serializeQuerystring(qs) {
          -   if (!qs) return '';
          -   var parts = [];
          +   if (!qs) return ''
          +   var parts = []
              for (var key in qs) {
          -     if (!Object.prototype.hasOwnProperty.call(qs, key)) continue;
          -     var entry = qs[key];
          -     if (!entry) continue;
          -     var encodedKey = encodeURIComponent(key);
          -     var primary = entry.value == null ? '' : entry.value;
          -     parts.push(encodedKey + '=' + encodeURIComponent(primary));
          +     if (!Object.prototype.hasOwnProperty.call(qs, key)) continue
          +     var entry = qs[key]
          +     if (!entry) continue
          +     var encodedKey = encodeURIComponent(key)
          +     var primary = entry.value == null ? '' : entry.value
          +     parts.push(encodedKey + '=' + encodeURIComponent(primary))
                if (entry.multiValue && entry.multiValue.length) {
                  for (var i = 0; i < entry.multiValue.length; i++) {
          -         var extra = entry.multiValue[i];
          -         var extraValue = extra && extra.value != null ? extra.value : '';
          -         parts.push(encodedKey + '=' + encodeURIComponent(extraValue));
          +         var extra = entry.multiValue[i]
          +         var extraValue = extra && extra.value != null ? extra.value : ''
          +         parts.push(encodedKey + '=' + encodeURIComponent(extraValue))
                  }
                }
              }
          -   return parts.length ? '?' + parts.join('&') : '';
          +   return parts.length ? '?' + parts.join('&') : ''
            }
            
            // biome-ignore lint/correctness/noUnusedVariables: CloudFront Function entry point
            // biome-ignore lint/complexity/useOptionalChain: cloudfront-js-2.0 does NOT support optional chaining
            function handler(event) {
          -   var request = event.request;
          -   var uri = request.uri;
          -   var host =
          -     request.headers && request.headers.host
          -       ? request.headers.host.value
          -       : undefined;
          +   var request = event.request
          +   var uri = request.uri
          +   var host = request.headers && request.headers.host ? request.headers.host.value : undefined
            
              if (host === 'www.iii.dev') {
          -     return redirect(
          -       `https://iii.dev${uri}${serializeQuerystring(request.querystring)}`,
          -     );
          +     return redirect(`https://iii.dev${uri}${serializeQuerystring(request.querystring)}`)
              }
            
          -   if (uri.indexOf('/.well-known/') === 0) return request;
          +   if (uri.indexOf('/.well-known/') === 0) return request
            
              // Pretty URLs → matching *.html objects in S3 (Option A). Add a key when you
              // ship a new top-level page as `pagename.html`.
              var htmlPretty = {
                '/manifesto': '/manifesto.html',
          -   };
          -   var htmlTarget = htmlPretty[uri];
          +   }
          +   var htmlTarget = htmlPretty[uri]
              if (htmlTarget !== undefined) {
          -     request.uri = htmlTarget;
          -     return request;
          +     request.uri = htmlTarget
          +     return request
              }
            
          +   // /blog/* — Astro emits build.format: 'directory' with trailingSlash:
          +   // 'always', so canonical URLs are /blog/<slug>/. CloudFront's
          +   // default_root_object only applies to the apex, so we rewrite directory
          +   // URLs to .../index.html and 301 extensionless paths to the canonical
          +   // trailing-slash form. Must run before the SPA fallback so /blog/<slug>
          +   // doesn't get hijacked into /index.html.
          +   var redirectHost = host || 'iii.dev'
          +   if (uri === '/blog') {
          +     return redirect('https://' + redirectHost + '/blog/' + serializeQuerystring(request.querystring))
          +   }
          +   if (uri.indexOf('/blog/') === 0) {
          +     if (uri.charAt(uri.length - 1) === '/') {
          +       request.uri = uri + 'index.html'
          +       return request
          +     }
          +     var lastSlashB = uri.lastIndexOf('/')
          +     var lastSegmentB = uri.substring(lastSlashB + 1)
          +     if (lastSegmentB.indexOf('.') === -1) {
          +       return redirect('https://' + redirectHost + uri + '/' + serializeQuerystring(request.querystring))
          +     }
          +     return request
          +   }
          + 
              // SPA fallback: extensionless path not ending in /
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
          -     const lastSlash = uri.lastIndexOf('/');
          -     const lastSegment = uri.substring(lastSlash + 1);
          +     const lastSlash = uri.lastIndexOf('/')
          +     const lastSegment = uri.substring(lastSlash + 1)
                if (lastSegment.indexOf('.') === -1) {
          -       request.uri = '/index.html';
          -       return request;
          +       request.uri = '/index.html'
          +       return request
                }
              }
            
          -   return request;
          +   return request
            }
        EOT
        id                           = "iii-website-prod-redirects"
        name                         = "iii-website-prod-redirects"
        # (8 unchanged attributes hidden)
    }

  # aws_route53_record.apex_a[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "apex_a" {
      - fqdn                             = "iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_iii.dev_A" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "A" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.apex_aaaa[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "apex_aaaa" {
      - fqdn                             = "iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_iii.dev_AAAA" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "AAAA" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.www_a[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "www_a" {
      - fqdn                             = "www.iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_www.iii.dev_A" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "www.iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "A" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.www_aaaa[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "www_aaaa" {
      - fqdn                             = "www.iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "www.iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "AAAA" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_sns_topic_subscription.email will be created
  + resource "aws_sns_topic_subscription" "email" {
      + arn                             = (known after apply)
      + confirmation_timeout_in_minutes = 1
      + confirmation_was_authenticated  = (known after apply)
      + endpoint                        = "devops@motia.dev"
      + endpoint_auto_confirms          = false
      + filter_policy_scope             = (known after apply)
      + id                              = (known after apply)
      + owner_id                        = (known after apply)
      + pending_confirmation            = (known after apply)
      + protocol                        = "email"
      + raw_message_delivery            = false
      + topic_arn                       = "arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms"
    }

Plan: 1 to add, 1 to change, 4 to destroy.

@anthonyiscoding anthonyiscoding removed the request for review from rohitg00 May 6, 2026 15:54
@ytallo ytallo merged commit bb9e444 into main May 6, 2026
37 checks passed
@ytallo ytallo deleted the add/blog branch May 6, 2026 16:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants