Skip to content

Commit f0b2686

Browse files
cdskillclaude
andcommitted
feat(seo): GSC verification, robots.txt, IndexNow on deploy
- infra: optional google_site_verification TXT on the apex (Route 53) - docs: robots.txt (allows AI crawlers) + IndexNow key file - ci: ping IndexNow after CloudFront invalidation; serve *.txt as must-revalidate instead of immutable Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6b32544 commit f0b2686

5 files changed

Lines changed: 69 additions & 4 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ jobs:
9090
echo "bucket=${{ vars.DOCS_S3_BUCKET }}" >> "$GITHUB_OUTPUT"
9191
echo "distribution=${{ vars.DOCS_CF_DISTRIBUTION_ID }}" >> "$GITHUB_OUTPUT"
9292
echo "role=${{ vars.DOCS_DEPLOY_ROLE_ARN }}" >> "$GITHUB_OUTPUT"
93+
# IndexNow: public domain + key (key file lives at apps/docs/public/<key>.txt).
94+
echo "host=qalma.dev" >> "$GITHUB_OUTPUT"
95+
echo "indexnow_key=57ae097f37a81fa242039798bfdb3226" >> "$GITHUB_OUTPUT"
9396
;;
9497
*)
9598
echo "::error::No deploy config for '${{ matrix.app }}'"; exit 1 ;;
@@ -102,21 +105,55 @@ jobs:
102105
aws-region: ${{ vars.AWS_REGION }}
103106

104107
# Pass 1 — hashed, content-addressed assets: cache forever, prune removed.
108+
# .txt (robots.txt, IndexNow key) is excluded so it isn't pinned immutable for a year.
105109
- name: Upload immutable assets
106110
run: |
107111
aws s3 sync "${{ steps.cfg.outputs.dist }}" "s3://${{ steps.cfg.outputs.bucket }}" \
108-
--delete --exclude "*.html" --exclude "*.xml" \
112+
--delete --exclude "*.html" --exclude "*.xml" --exclude "*.txt" \
109113
--cache-control "public, max-age=31536000, immutable"
110114
111-
# Pass 2 — HTML + sitemap: always revalidate so deploys show up instantly.
112-
- name: Upload HTML + sitemap (revalidate)
115+
# Pass 2 — HTML, sitemap, robots.txt + IndexNow key: always revalidate so
116+
# deploys show up instantly.
117+
- name: Upload HTML, sitemap, txt (revalidate)
113118
run: |
114119
aws s3 sync "${{ steps.cfg.outputs.dist }}" "s3://${{ steps.cfg.outputs.bucket }}" \
115-
--exclude "*" --include "*.html" --include "*.xml" \
120+
--exclude "*" --include "*.html" --include "*.xml" --include "*.txt" \
116121
--cache-control "public, max-age=0, must-revalidate"
117122
118123
# "/*" = one invalidation path (free up to 1000/month), fresh HTML at every edge.
119124
- name: Invalidate CloudFront
120125
run: |
121126
aws cloudfront create-invalidation \
122127
--distribution-id "${{ steps.cfg.outputs.distribution }}" --paths "/*"
128+
129+
# Tell IndexNow (Bing, Yandex, Seznam…; Google is trialing it) about the current
130+
# pages so they get crawled in minutes, not days. URLs come from the freshly built
131+
# sitemap. Non-blocking — a failed ping never fails the deploy. Skipped for apps
132+
# that don't set an IndexNow `host` in the case above.
133+
- name: Ping IndexNow
134+
if: steps.cfg.outputs.host != ''
135+
shell: bash
136+
run: |
137+
host="${{ steps.cfg.outputs.host }}"
138+
key="${{ steps.cfg.outputs.indexnow_key }}"
139+
sitemap="${{ steps.cfg.outputs.dist }}/sitemap.xml"
140+
url_json=$(grep -oE '<loc>[^<]+</loc>' "$sitemap" | sed -E 's#</?loc>##g' | jq -R . | jq -s 'map(select(length > 0))')
141+
count=$(echo "$url_json" | jq 'length')
142+
if [ "$count" -eq 0 ]; then
143+
echo "IndexNow: no URLs found in $sitemap, skipping."
144+
exit 0
145+
fi
146+
payload=$(jq -n \
147+
--arg host "$host" \
148+
--arg key "$key" \
149+
--arg keyLocation "https://$host/$key.txt" \
150+
--argjson urlList "$url_json" \
151+
'{host: $host, key: $key, keyLocation: $keyLocation, urlList: $urlList}')
152+
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST 'https://api.indexnow.org/indexnow' \
153+
-H 'Content-Type: application/json; charset=utf-8' \
154+
-d "$payload")
155+
echo "IndexNow → HTTP $code for $count URL(s)"
156+
case "$code" in
157+
200|202) ;;
158+
*) echo "::warning::IndexNow ping returned $code (non-blocking)" ;;
159+
esac
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
57ae097f37a81fa242039798bfdb3226

apps/docs/public/robots.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# https://qalma.dev/robots.txt
2+
# Allow everything, including AI crawlers (GPTBot, ClaudeBot, PerplexityBot, …)
3+
# so the docs can be cited in AI answers — see GEO / AI-visibility goal.
4+
User-agent: *
5+
Allow: /
6+
7+
Sitemap: https://qalma.dev/sitemap.xml

infra/terraform/dns_cert.tf

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,17 @@ resource "aws_route53_record" "aaaa" {
6363
evaluate_target_health = false
6464
}
6565
}
66+
67+
# Google Search Console — domain-property ownership (apex TXT).
68+
# Route 53 stores every TXT value for a name as ONE record set: any future apex
69+
# TXT (SPF, other site verifications) must be ADDED to this resource's `records`
70+
# list, not declared as a separate aws_route53_record (that would conflict).
71+
resource "aws_route53_record" "google_site_verification" {
72+
count = var.google_site_verification != "" ? 1 : 0
73+
74+
zone_id = data.aws_route53_zone.this.zone_id
75+
name = var.domain_name
76+
type = "TXT"
77+
records = [var.google_site_verification]
78+
ttl = 300
79+
}

infra/terraform/variables.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ variable "route53_zone_name" {
1414
type = string
1515
}
1616

17+
variable "google_site_verification" {
18+
description = "Google Search Console domain-property TXT verification token (the full 'google-site-verification=...' string). Empty = no record created."
19+
type = string
20+
default = ""
21+
}
22+
1723
variable "github_repo" {
1824
description = "owner/repo allowed to assume the deploy role via GitHub OIDC."
1925
type = string

0 commit comments

Comments
 (0)