Skip to content

Commit 153bea0

Browse files
flufflycthu1uclaude
andcommitted
Add redirect link system and scope Pages publishing to its own job
Posts now link to cutepetsboston.com/r/?id=<slug> rather than straight to the shelter listing, so click-through can be attributed per pet (RFC 0001). The slug -> adoption URL mapping is append-only and lives on the gh-pages branch; docs/r/index.html resolves it client-side and falls back to the homepage when a slug is unknown or the fetch fails. Pages publishing moves into a reusable workflow, publish-pages.yml, called by both prod.yml and deploy-pages.yml: - prod.yml splits into two jobs so the job that posts pets runs with a read-only token; only the publish job holds contents/pages/id-token write. A composite action could not do this, as it inherits the calling job's token. - Both callers share the pages-publish concurrency group, so a docs push and the 4-hourly cron can no longer deploy Pages at the same time. - The mapping is merged rather than copied (jq -s '.[0] * .[1]' with gh-pages winning conflicts), so a failed fetch can no longer wipe existing redirects. - The deploy job declares environment: github-pages, which actions/deploy-pages requires and prod.yml previously lacked. Fixes in redirects.py and the interstitial: - a corrupt mapping no longer blocks posting; it logs and posts the raw URL, leaving the damaged file untouched rather than silently rebuilding it - slugs are injective, so two distinct pet ids can no longer collapse onto one slug and point a post at the wrong pet's listing - load_redirects/save_redirects resolve the default path at call time rather than binding it at import - only http(s) targets are followed or recorded, so a javascript: URL coming from the RescueGroups API cannot execute on our own origin - redirects.json is gitignored; the gh-pages commit therefore uses git add -f docs/specs/redirect-pipeline.md diagrams the pipeline as built and how the planned analytics page attaches to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1206244 commit 153bea0

11 files changed

Lines changed: 1031 additions & 3 deletions

File tree

.github/workflows/deploy-pages.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Deploy Pages
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
paths:
8+
- 'docs/**'
9+
- '.github/workflows/deploy-pages.yml'
10+
- '.github/workflows/publish-pages.yml'
11+
workflow_dispatch:
12+
13+
jobs:
14+
# Redeploys the site with whatever mapping gh-pages already holds. No
15+
# mapping_artifact, so publish-pages skips its gh-pages commit entirely --
16+
# which is why this needs only contents: read.
17+
publish:
18+
permissions:
19+
contents: read
20+
pages: write # deploy Pages
21+
id-token: write # OIDC for actions/deploy-pages
22+
uses: ./.github/workflows/publish-pages.yml

.github/workflows/prod.yml

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,28 @@ on:
66
# Every 4 hours
77
- cron: "0 */4 * * *"
88

9+
# Default for jobs that do not set their own. Job-level permissions REPLACE
10+
# these wholesale rather than merging with them -- see both jobs below.
911
permissions:
10-
actions: read
11-
12+
actions: read
13+
contents: read
14+
1215
# Limits to executing one workflow in a concurrency group at any time
1316
# Will queue 1 PENDING workflow run. New incoming runs cancel & replace the pending run
1417
concurrency:
1518
group: ${{ github.workflow }}-${{ github.ref }}
1619
queue: single
1720

1821
jobs:
22+
# Posts a pet. Read-only token on purpose: this job pip-installs deps and
23+
# feeds untrusted RescueGroups API responses to python, so it must not be
24+
# able to write to the repo. Persisting the mapping is publish-redirects'
25+
# job, below.
1926
run-cute-pets:
2027
runs-on: ubuntu-latest
28+
permissions:
29+
actions: read # gh run list, to find the previous database artifact
30+
contents: read
2131
steps:
2232
- name: Checkout repo
2333
uses: actions/checkout@v6
@@ -64,6 +74,7 @@ jobs:
6474
MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }}
6575
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
6676
APP_ENV: prod
77+
REDIRECTS_ENABLED: true
6778
run: python ./main.py
6879

6980
- name: Upload database artifact
@@ -73,10 +84,36 @@ jobs:
7384
retention-days: 14
7485
archive: false
7586

87+
- name: Upload redirect mapping
88+
# Handed to publish-redirects, which merges it into the authoritative
89+
# copy on gh-pages. Absent when nothing was minted, which that job
90+
# treats as a no-op.
91+
if: ${{ !cancelled() && hashFiles('redirects.json') != '' }}
92+
uses: actions/upload-artifact@v7
93+
with:
94+
name: redirects-mapping
95+
path: redirects.json
96+
retention-days: 1
97+
archive: false
98+
7699
- name: Upload API Log artifact
77100
if: '!cancelled()' #This ensures this step runs even if the previous steps failed only if manually cancelled it doesnt run
78101
uses: actions/upload-artifact@v7
79102
with:
80103
path: cutepets.log
81104
retention-days: 90
82105
archive: false
106+
107+
# Separate job so the elevated token never coexists with the posting steps.
108+
# Shares the pages-publish concurrency group with deploy-pages.yml, so the
109+
# two can no longer deploy Pages at the same time.
110+
publish-redirects:
111+
needs: run-cute-pets
112+
if: '!cancelled()' # still record the mapping if a later step failed after posting
113+
permissions:
114+
contents: write # push the mapping to gh-pages
115+
pages: write # deploy Pages
116+
id-token: write # OIDC for actions/deploy-pages
117+
uses: ./.github/workflows/publish-pages.yml
118+
with:
119+
mapping_artifact: redirects-mapping
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
name: Publish Pages
2+
3+
# Reusable workflow (RFC 0001). Assembles docs/ plus the redirect mapping and
4+
# deploys GitHub Pages; when handed a mapping artifact it also records the newly
5+
# minted redirects on the gh-pages branch.
6+
#
7+
# Lives in its own workflow rather than a composite action for two reasons:
8+
# * a composite action runs inside the CALLING job and inherits its token, so
9+
# it could not hold narrower permissions than the job that posts pets;
10+
# * both callers share this workflow's concurrency group, which is what stops
11+
# two Pages deployments from overlapping.
12+
#
13+
# The CALLING job must grant:
14+
# contents: write -- only when passing mapping_artifact (push to gh-pages)
15+
# pages: write -- deploy Pages
16+
# id-token: write -- OIDC for actions/deploy-pages
17+
18+
on:
19+
workflow_call:
20+
inputs:
21+
mapping_artifact:
22+
description: 'Artifact holding a newly minted redirects.json. Empty = redeploy only.'
23+
type: string
24+
required: false
25+
default: ''
26+
27+
jobs:
28+
publish:
29+
runs-on: ubuntu-latest
30+
31+
# Repo-scoped group shared by every caller, so the scheduled posting run and
32+
# a docs push can never deploy Pages at the same time.
33+
concurrency:
34+
group: pages-publish
35+
cancel-in-progress: false
36+
37+
# actions/deploy-pages requires the job to declare this environment.
38+
environment:
39+
name: github-pages
40+
url: ${{ steps.deployment.outputs.page_url }}
41+
42+
steps:
43+
- name: Checkout repo
44+
uses: actions/checkout@v6
45+
46+
- name: Download newly minted redirects
47+
if: ${{ inputs.mapping_artifact != '' }}
48+
continue-on-error: true # no artifact (nothing minted, or the run failed early) is fine
49+
uses: actions/download-artifact@v8
50+
with:
51+
name: ${{ inputs.mapping_artifact }}
52+
path: minted
53+
54+
- name: Checkout gh-pages (redirect mapping)
55+
continue-on-error: true # the branch does not exist on the very first run
56+
uses: actions/checkout@v6
57+
with:
58+
ref: gh-pages
59+
path: gh-pages
60+
61+
- name: Merge redirect mapping
62+
run: |
63+
# RFC 0001 constraint 4: the mapping is append-only and permanent --
64+
# losing an entry breaks a link already posted to social media.
65+
mkdir -p minted gh-pages
66+
[ -f minted/redirects.json ] || echo '{}' > minted/redirects.json
67+
[ -f gh-pages/redirects.json ] || echo '{}' > gh-pages/redirects.json
68+
cp gh-pages/redirects.json previous.json
69+
70+
# Argument order is load-bearing: the second operand wins every key
71+
# collision, so gh-pages (the authoritative copy) always beats the
72+
# freshly minted file. A missing or empty minted file is therefore a
73+
# no-op and can never delete an existing redirect. Do NOT swap these.
74+
# jq failing here (a corrupt authoritative mapping) fails the step by
75+
# design -- pushing a merge derived from an unreadable file is exactly
76+
# the data loss this ordering exists to prevent.
77+
jq -s '.[0] * .[1]' minted/redirects.json previous.json > merged.json
78+
79+
# Reporting only -- never let a logging quirk fail the publish.
80+
jq -r -n --slurpfile new merged.json --slurpfile old previous.json \
81+
'(($new[0] | keys) - ($old[0] | keys)) as $added
82+
| if ($added | length) == 0 then "No new redirects."
83+
else "New redirects: " + ($added | join(", ")) end' || true
84+
echo "Mapping now holds $(jq 'length' merged.json || echo '?') redirect(s)."
85+
86+
- name: Record mapping on gh-pages
87+
if: ${{ inputs.mapping_artifact != '' }}
88+
env:
89+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
90+
run: |
91+
if [ ! -d gh-pages/.git ]; then
92+
echo "gh-pages branch does not exist yet; initializing it."
93+
git -C gh-pages init -q -b gh-pages
94+
git -C gh-pages remote add origin \
95+
"https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
96+
fi
97+
cp merged.json gh-pages/redirects.json
98+
cd gh-pages
99+
git config user.name "github-actions[bot]"
100+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
101+
102+
# -f: master's .gitignore ignores the generated redirects.json, and
103+
# gh-pages may carry a copy of that file. Without -f, git add exits 1
104+
# and stages nothing, silently ending the redirect system.
105+
git add -f redirects.json
106+
if git diff --cached --quiet; then
107+
echo "No new redirects to record."
108+
exit 0
109+
fi
110+
111+
# Commit BEFORE any pull. "git pull --rebase" refuses to run with a
112+
# dirty index (exit 128) even when the remote has not moved, so
113+
# pulling first would fail on every run that mints a redirect.
114+
git commit -q -m "Record redirect mapping (automated)"
115+
git push -q origin HEAD:gh-pages || {
116+
git pull --rebase -q origin gh-pages
117+
git push -q origin HEAD:gh-pages
118+
}
119+
echo "Redirect mapping updated on gh-pages."
120+
121+
- name: Assemble Pages site
122+
run: |
123+
mkdir -p _site
124+
cp -r docs/. _site/
125+
cp merged.json _site/redirects.json
126+
127+
- name: Upload Pages artifact
128+
uses: actions/upload-pages-artifact@v5
129+
with:
130+
path: _site
131+
132+
- name: Deploy to GitHub Pages
133+
id: deployment
134+
uses: actions/deploy-pages@v5

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ image.png
3131

3232
# Ignore because this is used by github artifacts
3333
database.json
34+
35+
# Generated by a redirect-minting run; the durable copy lives on gh-pages.
36+
/redirects.json

config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
CITY_HASHTAGS = ["Boston"]
44
POSTAL_CODE = "02108"
55

6+
# Public site root (RFC 0001): redirect links are minted as
7+
# {SITE_URL}/r/?id=<slug> so we own the hop and get click attribution.
8+
SITE_URL = "https://www.cutepetsboston.com"
9+
610
# RescueGroups API plural species names for the species we post about.
711
PET_SPECIES = ("dogs", "cats")
812

docs/r/index.html

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<meta name="robots" content="noindex" />
7+
<title>Redirecting&hellip; | CutePetsBoston</title>
8+
<link rel="icon" href="../favicon.ico" sizes="any" />
9+
<link rel="icon" type="image/svg+xml" href="../favicon.svg" />
10+
<link rel="apple-touch-icon" href="../apple-touch-icon.png" />
11+
<link rel="stylesheet" href="../styles.css" />
12+
<noscript>
13+
<!-- Without JS we cannot look up the slug, so fall back to the homepage. -->
14+
<meta http-equiv="refresh" content="0;url=https://www.cutepetsboston.com/" />
15+
</noscript>
16+
</head>
17+
<body>
18+
<main class="container" style="padding-top: 3.5rem; text-align: center;">
19+
<p class="dashboard-note" id="message">Taking you to the listing&hellip;</p>
20+
<p><a class="btn btn-primary" href="https://www.cutepetsboston.com/">Go to CutePetsBoston</a></p>
21+
<noscript>
22+
<p>JavaScript is required to follow this link. <a href="https://www.cutepetsboston.com/">Click here to continue.</a></p>
23+
</noscript>
24+
</main>
25+
26+
<script>
27+
const HOME_URL = "https://www.cutepetsboston.com/";
28+
const REDIRECTS_URL = "/redirects.json";
29+
const messageEl = document.getElementById("message");
30+
31+
const goHome = (reason) => {
32+
messageEl.textContent = reason;
33+
location.replace(HOME_URL);
34+
};
35+
36+
// location.replace() would execute a "javascript:" target in our own origin,
37+
// and adoption URLs come from the RescueGroups API verbatim, so only ever
38+
// navigate to plain http(s). The protocol check is the guard, not the parse:
39+
// new URL("javascript:alert(1)") parses fine and yields protocol
40+
// "javascript:". Passing no base also rejects relative values.
41+
const isSafeUrl = (value) => {
42+
try {
43+
const parsed = new URL(value);
44+
return parsed.protocol === "https:" || parsed.protocol === "http:";
45+
} catch (e) {
46+
return false;
47+
}
48+
};
49+
50+
const params = new URLSearchParams(location.search);
51+
const id = params.get("id");
52+
53+
if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) {
54+
goHome("This link is no longer valid — taking you home…");
55+
} else {
56+
fetch(REDIRECTS_URL)
57+
.then((response) => {
58+
if (!response.ok) {
59+
throw new Error(`redirects.json returned ${response.status}`);
60+
}
61+
return response.json();
62+
})
63+
.then((redirects) => {
64+
const url = redirects[id];
65+
if (url && isSafeUrl(url)) {
66+
location.replace(url);
67+
} else if (url) {
68+
console.warn("Refusing to follow an unsafe redirect target for id:", id);
69+
goHome("This link is no longer valid — taking you home…");
70+
} else {
71+
goHome("This pet's listing is no longer available — taking you home…");
72+
}
73+
})
74+
.catch(() => {
75+
goHome("Couldn't load the listing right now — taking you home…");
76+
});
77+
}
78+
</script>
79+
</body>
80+
</html>

0 commit comments

Comments
 (0)