Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Deploy Pages

on:
push:
branches:
- master
paths:
- 'docs/**'
- '.github/workflows/deploy-pages.yml'
- '.github/workflows/publish-pages.yml'
workflow_dispatch:

jobs:
# Redeploys the site with whatever mapping gh-pages already holds. No
# mapping_artifact, so publish-pages skips its gh-pages commit entirely --
# which is why this needs only contents: read.
publish:
permissions:
contents: read
pages: write # deploy Pages
id-token: write # OIDC for actions/deploy-pages
uses: ./.github/workflows/publish-pages.yml
41 changes: 39 additions & 2 deletions .github/workflows/prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,28 @@ on:
# Every 4 hours
- cron: "0 */4 * * *"

# Default for jobs that do not set their own. Job-level permissions REPLACE
# these wholesale rather than merging with them -- see both jobs below.
permissions:
actions: read

actions: read
contents: read

# Limits to executing one workflow in a concurrency group at any time
# Will queue 1 PENDING workflow run. New incoming runs cancel & replace the pending run
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
queue: single

jobs:
# Posts a pet. Read-only token on purpose: this job pip-installs deps and
# feeds untrusted RescueGroups API responses to python, so it must not be
# able to write to the repo. Persisting the mapping is publish-redirects'
# job, below.
run-cute-pets:
runs-on: ubuntu-latest
permissions:
actions: read # gh run list, to find the previous database artifact
contents: read
steps:
- name: Checkout repo
uses: actions/checkout@v6
Expand Down Expand Up @@ -64,6 +74,7 @@ jobs:
MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
APP_ENV: prod
REDIRECTS_ENABLED: true
run: python ./main.py

- name: Upload database artifact
Expand All @@ -73,10 +84,36 @@ jobs:
retention-days: 14
archive: false

- name: Upload redirect mapping
# Handed to publish-redirects, which merges it into the authoritative
# copy on gh-pages. Absent when nothing was minted, which that job
# treats as a no-op.
if: ${{ !cancelled() && hashFiles('redirects.json') != '' }}
uses: actions/upload-artifact@v7
with:
name: redirects-mapping
path: redirects.json
retention-days: 1
archive: false

- name: Upload API Log artifact
if: '!cancelled()' #This ensures this step runs even if the previous steps failed only if manually cancelled it doesnt run
uses: actions/upload-artifact@v7
with:
path: cutepets.log
retention-days: 90
archive: false

# Separate job so the elevated token never coexists with the posting steps.
# Shares the pages-publish concurrency group with deploy-pages.yml, so the
# two can no longer deploy Pages at the same time.
publish-redirects:
needs: run-cute-pets
if: '!cancelled()' # still record the mapping if a later step failed after posting
permissions:
contents: write # push the mapping to gh-pages
pages: write # deploy Pages
id-token: write # OIDC for actions/deploy-pages
uses: ./.github/workflows/publish-pages.yml
with:
mapping_artifact: redirects-mapping
134 changes: 134 additions & 0 deletions .github/workflows/publish-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
name: Publish Pages

# Reusable workflow (RFC 0001). Assembles docs/ plus the redirect mapping and
# deploys GitHub Pages; when handed a mapping artifact it also records the newly
# minted redirects on the gh-pages branch.
#
# Lives in its own workflow rather than a composite action for two reasons:
# * a composite action runs inside the CALLING job and inherits its token, so
# it could not hold narrower permissions than the job that posts pets;
# * both callers share this workflow's concurrency group, which is what stops
# two Pages deployments from overlapping.
#
# The CALLING job must grant:
# contents: write -- only when passing mapping_artifact (push to gh-pages)
# pages: write -- deploy Pages
# id-token: write -- OIDC for actions/deploy-pages

on:
workflow_call:
inputs:
mapping_artifact:
description: 'Artifact holding a newly minted redirects.json. Empty = redeploy only.'
type: string
required: false
default: ''

jobs:
publish:
runs-on: ubuntu-latest

# Repo-scoped group shared by every caller, so the scheduled posting run and
# a docs push can never deploy Pages at the same time.
concurrency:
group: pages-publish
cancel-in-progress: false

# actions/deploy-pages requires the job to declare this environment.
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

steps:
- name: Checkout repo
uses: actions/checkout@v6

- name: Download newly minted redirects
if: ${{ inputs.mapping_artifact != '' }}
continue-on-error: true # no artifact (nothing minted, or the run failed early) is fine
uses: actions/download-artifact@v8
with:
name: ${{ inputs.mapping_artifact }}
path: minted

- name: Checkout gh-pages (redirect mapping)
continue-on-error: true # the branch does not exist on the very first run
uses: actions/checkout@v6
with:
ref: gh-pages
path: gh-pages

- name: Merge redirect mapping
run: |
# RFC 0001 constraint 4: the mapping is append-only and permanent --
# losing an entry breaks a link already posted to social media.
mkdir -p minted gh-pages
[ -f minted/redirects.json ] || echo '{}' > minted/redirects.json
[ -f gh-pages/redirects.json ] || echo '{}' > gh-pages/redirects.json
cp gh-pages/redirects.json previous.json

# Argument order is load-bearing: the second operand wins every key
# collision, so gh-pages (the authoritative copy) always beats the
# freshly minted file. A missing or empty minted file is therefore a
# no-op and can never delete an existing redirect. Do NOT swap these.
# jq failing here (a corrupt authoritative mapping) fails the step by
# design -- pushing a merge derived from an unreadable file is exactly
# the data loss this ordering exists to prevent.
jq -s '.[0] * .[1]' minted/redirects.json previous.json > merged.json

# Reporting only -- never let a logging quirk fail the publish.
jq -r -n --slurpfile new merged.json --slurpfile old previous.json \
'(($new[0] | keys) - ($old[0] | keys)) as $added
| if ($added | length) == 0 then "No new redirects."
else "New redirects: " + ($added | join(", ")) end' || true
echo "Mapping now holds $(jq 'length' merged.json || echo '?') redirect(s)."

- name: Record mapping on gh-pages
if: ${{ inputs.mapping_artifact != '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ ! -d gh-pages/.git ]; then
echo "gh-pages branch does not exist yet; initializing it."
git -C gh-pages init -q -b gh-pages
git -C gh-pages remote add origin \
"https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
fi
cp merged.json gh-pages/redirects.json
cd gh-pages
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

# -f: master's .gitignore ignores the generated redirects.json, and
# gh-pages may carry a copy of that file. Without -f, git add exits 1
# and stages nothing, silently ending the redirect system.
git add -f redirects.json
if git diff --cached --quiet; then
echo "No new redirects to record."
exit 0
fi

# Commit BEFORE any pull. "git pull --rebase" refuses to run with a
# dirty index (exit 128) even when the remote has not moved, so
# pulling first would fail on every run that mints a redirect.
git commit -q -m "Record redirect mapping (automated)"
git push -q origin HEAD:gh-pages || {
git pull --rebase -q origin gh-pages
git push -q origin HEAD:gh-pages
}
echo "Redirect mapping updated on gh-pages."

- name: Assemble Pages site
run: |
mkdir -p _site
cp -r docs/. _site/
cp merged.json _site/redirects.json

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: _site

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ image.png

# Ignore because this is used by github artifacts
database.json

# Generated by a redirect-minting run; the durable copy lives on gh-pages.
/redirects.json
4 changes: 4 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
CITY_HASHTAGS = ["Boston"]
POSTAL_CODE = "02108"

# Public site root (RFC 0001): redirect links are minted as
# {SITE_URL}/r/?id=<slug> so we own the hop and get click attribution.
SITE_URL = "https://www.cutepetsboston.com"

# RescueGroups API plural species names for the species we post about.
PET_SPECIES = ("dogs", "cats")

Expand Down
80 changes: 80 additions & 0 deletions docs/r/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Redirecting&hellip; | CutePetsBoston</title>
<link rel="icon" href="../favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="../favicon.svg" />
<link rel="apple-touch-icon" href="../apple-touch-icon.png" />
<link rel="stylesheet" href="../styles.css" />
<noscript>
<!-- Without JS we cannot look up the slug, so fall back to the homepage. -->
<meta http-equiv="refresh" content="0;url=https://www.cutepetsboston.com/" />
</noscript>
</head>
<body>
<main class="container" style="padding-top: 3.5rem; text-align: center;">
<p class="dashboard-note" id="message">Taking you to the listing&hellip;</p>
<p><a class="btn btn-primary" href="https://www.cutepetsboston.com/">Go to CutePetsBoston</a></p>
<noscript>
<p>JavaScript is required to follow this link. <a href="https://www.cutepetsboston.com/">Click here to continue.</a></p>
</noscript>
</main>

<script>
const HOME_URL = "https://www.cutepetsboston.com/";
const REDIRECTS_URL = "/redirects.json";
const messageEl = document.getElementById("message");

const goHome = (reason) => {
messageEl.textContent = reason;
location.replace(HOME_URL);
};

// location.replace() would execute a "javascript:" target in our own origin,
// and adoption URLs come from the RescueGroups API verbatim, so only ever
// navigate to plain http(s). The protocol check is the guard, not the parse:
// new URL("javascript:alert(1)") parses fine and yields protocol
// "javascript:". Passing no base also rejects relative values.
const isSafeUrl = (value) => {
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch (e) {
return false;
}
};

const params = new URLSearchParams(location.search);
const id = params.get("id");

if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) {
goHome("This link is no longer valid — taking you home…");
} else {
fetch(REDIRECTS_URL)
.then((response) => {
if (!response.ok) {
throw new Error(`redirects.json returned ${response.status}`);
}
return response.json();
})
.then((redirects) => {
const url = redirects[id];
if (url && isSafeUrl(url)) {
location.replace(url);
} else if (url) {
console.warn("Refusing to follow an unsafe redirect target for id:", id);
goHome("This link is no longer valid — taking you home…");
} else {
goHome("This pet's listing is no longer available — taking you home…");
}
})
.catch(() => {
goHome("Couldn't load the listing right now — taking you home…");
});
}
</script>
</body>
</html>
Loading