Skip to content

Sync Heatmaps

Sync Heatmaps #319

Workflow file for this run

name: Sync Heatmaps
on:
schedule:
- cron: "0 15 * * *"
workflow_dispatch:
inputs:
source:
description: "Data source"
required: false
type: choice
options:
- strava
- garmin
default: strava
update_readme_link:
description: "Update README dashboard URL in this fork"
required: false
type: boolean
default: true
full_backfill:
description: "Reset backfill cursor and re-fetch full history for the selected source"
required: false
type: boolean
default: false
permissions:
contents: write
pages: read
env:
DASHBOARD_DATA_BRANCH: dashboard-data
concurrency:
group: sync-${{ github.repository }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Resolve source
env:
WORKFLOW_SOURCE: ${{ github.event_name == 'workflow_dispatch' && inputs.source || '' }}
DASHBOARD_SOURCE: ${{ vars.DASHBOARD_SOURCE }}
run: |
source="${WORKFLOW_SOURCE:-${DASHBOARD_SOURCE:-strava}}"
source="$(echo "${source}" | tr '[:upper:]' '[:lower:]' | xargs)"
if [[ "${source}" != "strava" && "${source}" != "garmin" ]]; then
echo "Unsupported source '${source}'. Expected strava or garmin."
exit 1
fi
echo "SYNC_SOURCE=${source}" >> "$GITHUB_ENV"
echo "Using source: ${source}"
- name: Validate required source secrets
env:
SYNC_SOURCE: ${{ env.SYNC_SOURCE }}
STRAVA_CLIENT_ID: ${{ secrets.STRAVA_CLIENT_ID }}
STRAVA_CLIENT_SECRET: ${{ secrets.STRAVA_CLIENT_SECRET }}
STRAVA_REFRESH_TOKEN: ${{ secrets.STRAVA_REFRESH_TOKEN }}
GARMIN_TOKENS_B64: ${{ secrets.GARMIN_TOKENS_B64 }}
GARMIN_EMAIL: ${{ secrets.GARMIN_EMAIL }}
GARMIN_PASSWORD: ${{ secrets.GARMIN_PASSWORD }}
run: |
missing=()
if [[ "${SYNC_SOURCE}" == "strava" ]]; then
[[ -n "$STRAVA_CLIENT_ID" ]] || missing+=("STRAVA_CLIENT_ID")
[[ -n "$STRAVA_CLIENT_SECRET" ]] || missing+=("STRAVA_CLIENT_SECRET")
[[ -n "$STRAVA_REFRESH_TOKEN" ]] || missing+=("STRAVA_REFRESH_TOKEN")
elif [[ "${SYNC_SOURCE}" == "garmin" ]]; then
if [[ -z "$GARMIN_TOKENS_B64" && ( -z "$GARMIN_EMAIL" || -z "$GARMIN_PASSWORD" ) ]]; then
missing+=("GARMIN_TOKENS_B64 or GARMIN_EMAIL+GARMIN_PASSWORD")
fi
else
missing+=("SYNC_SOURCE must be strava or garmin")
fi
if [ ${#missing[@]} -gt 0 ]; then
echo "❌ Missing required repository secrets: ${missing[*]}"
echo "Add them in Settings -> Secrets and variables -> Actions, then re-run this workflow."
exit 1
fi
- name: Write config.local.yaml
env:
SYNC_SOURCE: ${{ env.SYNC_SOURCE }}
STRAVA_CLIENT_ID: ${{ secrets.STRAVA_CLIENT_ID }}
STRAVA_CLIENT_SECRET: ${{ secrets.STRAVA_CLIENT_SECRET }}
STRAVA_REFRESH_TOKEN: ${{ secrets.STRAVA_REFRESH_TOKEN }}
DASHBOARD_STRAVA_PROFILE_URL: ${{ vars.DASHBOARD_STRAVA_PROFILE_URL }}
DASHBOARD_STRAVA_ACTIVITY_LINKS: ${{ vars.DASHBOARD_STRAVA_ACTIVITY_LINKS }}
DASHBOARD_GARMIN_PROFILE_URL: ${{ vars.DASHBOARD_GARMIN_PROFILE_URL }}
DASHBOARD_GARMIN_ACTIVITY_LINKS: ${{ vars.DASHBOARD_GARMIN_ACTIVITY_LINKS }}
GARMIN_TOKENS_B64: ${{ secrets.GARMIN_TOKENS_B64 }}
GARMIN_EMAIL: ${{ secrets.GARMIN_EMAIL }}
GARMIN_PASSWORD: ${{ secrets.GARMIN_PASSWORD }}
DASHBOARD_DISTANCE_UNIT: ${{ vars.DASHBOARD_DISTANCE_UNIT }}
DASHBOARD_ELEVATION_UNIT: ${{ vars.DASHBOARD_ELEVATION_UNIT }}
DASHBOARD_WEEK_START: ${{ vars.DASHBOARD_WEEK_START }}
run: |
cat <<CONFIG > config.local.yaml
source: "${SYNC_SOURCE}"
CONFIG
if [[ "${SYNC_SOURCE}" == "strava" ]]; then
cat <<CONFIG >> config.local.yaml
strava:
client_id: "${STRAVA_CLIENT_ID}"
client_secret: "${STRAVA_CLIENT_SECRET}"
refresh_token: "${STRAVA_REFRESH_TOKEN}"
CONFIG
if [[ -n "${DASHBOARD_STRAVA_PROFILE_URL}" ]]; then
echo " profile_url: \"${DASHBOARD_STRAVA_PROFILE_URL}\"" >> config.local.yaml
fi
normalized_links_value="$(echo "${DASHBOARD_STRAVA_ACTIVITY_LINKS}" | tr '[:upper:]' '[:lower:]' | xargs)"
if [[ "${normalized_links_value}" == "1" || "${normalized_links_value}" == "true" || "${normalized_links_value}" == "yes" || "${normalized_links_value}" == "y" || "${normalized_links_value}" == "on" ]]; then
echo " include_activity_urls: true" >> config.local.yaml
fi
else
cat <<CONFIG >> config.local.yaml
garmin:
token_store_b64: "${GARMIN_TOKENS_B64}"
email: "${GARMIN_EMAIL}"
password: "${GARMIN_PASSWORD}"
CONFIG
if [[ -n "${DASHBOARD_GARMIN_PROFILE_URL}" ]]; then
echo " profile_url: \"${DASHBOARD_GARMIN_PROFILE_URL}\"" >> config.local.yaml
fi
normalized_links_value="$(echo "${DASHBOARD_GARMIN_ACTIVITY_LINKS}" | tr '[:upper:]' '[:lower:]' | xargs)"
if [[ "${normalized_links_value}" == "1" || "${normalized_links_value}" == "true" || "${normalized_links_value}" == "yes" || "${normalized_links_value}" == "y" || "${normalized_links_value}" == "on" ]]; then
echo " include_activity_urls: true" >> config.local.yaml
fi
fi
if [[ -n "${DASHBOARD_DISTANCE_UNIT}" || -n "${DASHBOARD_ELEVATION_UNIT}" ]]; then
cat <<'CONFIG' >> config.local.yaml
units:
CONFIG
if [[ -n "${DASHBOARD_DISTANCE_UNIT}" ]]; then
echo " distance: \"${DASHBOARD_DISTANCE_UNIT}\"" >> config.local.yaml
fi
if [[ -n "${DASHBOARD_ELEVATION_UNIT}" ]]; then
echo " elevation: \"${DASHBOARD_ELEVATION_UNIT}\"" >> config.local.yaml
fi
fi
if [[ -n "${DASHBOARD_WEEK_START}" ]]; then
echo "heatmaps:" >> config.local.yaml
echo " week_start: \"${DASHBOARD_WEEK_START}\"" >> config.local.yaml
fi
echo "Generated config.local.yaml for source=${SYNC_SOURCE}"
- name: Apply source-repo overrides (optional)
if: ${{ github.repository == 'aspain/git-sweaty' }}
env:
DASHBOARD_EXCLUDE_TYPES: ${{ vars.DASHBOARD_EXCLUDE_TYPES }}
run: |
if [[ -z "${DASHBOARD_EXCLUDE_TYPES}" ]]; then
echo "No source-repo overrides configured."
exit 0
fi
echo "activities:" >> config.local.yaml
echo " exclude_types:" >> config.local.yaml
IFS=',' read -ra TYPES <<< "${DASHBOARD_EXCLUDE_TYPES}"
for raw_type in "${TYPES[@]}"; do
sport_type="$(echo "${raw_type}" | xargs)"
if [[ -n "${sport_type}" ]]; then
echo " - ${sport_type}" >> config.local.yaml
fi
done
- name: Restore persisted state
env:
FULL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && inputs.full_backfill || false }}
run: |
set -euo pipefail
branch="${DASHBOARD_DATA_BRANCH}"
if git ls-remote --exit-code --heads origin "${branch}" >/dev/null; then
git fetch origin "${branch}"
rm -rf data site/data.json
git restore --source "origin/${branch}" --worktree -- data || true
git restore --source "origin/${branch}" --worktree -- site/data.json || true
echo "Restored persisted state from ${branch}."
else
echo "No ${branch} branch yet; starting with an empty state."
fi
if [ "${FULL_BACKFILL}" = "true" ]; then
rm -f data/activities_normalized.json
rm -f data/daily_aggregates.json
rm -f data/backfill_state.json
rm -f data/backfill_state_strava.json
rm -f data/backfill_state_garmin.json
rm -f data/last_sync_summary.json
rm -f data/last_sync_summary.txt
rm -f data/source_state.json
rm -f site/data.json
echo "Full backfill requested: reset persisted pipeline outputs and backfill cursor."
fi
- name: Run pipeline
env:
DASHBOARD_REPO: ${{ vars.DASHBOARD_REPO }}
GITHUB_TOKEN: ${{ github.token }}
UPDATE_README_LINK: ${{ github.event_name != 'workflow_dispatch' || inputs.update_readme_link }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
args=()
if [ "${UPDATE_README_LINK}" = "true" ]; then
args+=(--update-readme-link)
fi
python scripts/run_pipeline.py "${args[@]}"
- name: Stamp repository in site data
env:
DASHBOARD_REPO: ${{ vars.DASHBOARD_REPO }}
run: |
set -euo pipefail
python - <<'PY'
import json
import os
import re
repo_re = re.compile(r"^[^/\s]+/[^/\s]+$")
configured_repo = os.environ.get("DASHBOARD_REPO", "").strip()
default_repo = os.environ.get("GITHUB_REPOSITORY", "").strip()
configured_valid = bool(repo_re.match(configured_repo or ""))
default_valid = bool(repo_re.match(default_repo or ""))
if (
configured_valid
and default_valid
and configured_repo != default_repo
and str(os.environ.get("GITHUB_ACTIONS", "")).strip().lower() == "true"
):
repo = default_repo
elif configured_valid:
repo = configured_repo
else:
repo = default_repo
path = "site/data.json"
try:
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
if repo_re.match(repo or ""):
payload["repo"] = repo
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=True, indent=2, sort_keys=True)
handle.write("\n")
PY
- name: Rotate Strava refresh secret (optional)
env:
SYNC_SOURCE: ${{ env.SYNC_SOURCE }}
STRAVA_REFRESH_TOKEN: ${{ secrets.STRAVA_REFRESH_TOKEN }}
STRAVA_SECRET_UPDATE_TOKEN: ${{ secrets.STRAVA_SECRET_UPDATE_TOKEN }}
run: |
set -euo pipefail
if [[ "${SYNC_SOURCE}" != "strava" ]]; then
echo "Skipping refresh-token rotation: source=${SYNC_SOURCE}."
exit 0
fi
if [[ ! -f ".strava_token.json" ]]; then
echo "No local Strava token cache found; skipping refresh-token rotation."
exit 0
fi
new_refresh_token="$(python -c 'import json; payload=json.load(open(".strava_token.json","r",encoding="utf-8")); value=payload.get("refresh_token"); print(value if isinstance(value,str) else "")' 2>/dev/null || true)"
if [[ -z "${new_refresh_token}" ]]; then
echo "No refreshed Strava token captured; skipping."
exit 0
fi
if [[ "${new_refresh_token}" == "${STRAVA_REFRESH_TOKEN}" ]]; then
echo "Strava refresh token unchanged."
exit 0
fi
if [[ -z "${STRAVA_SECRET_UPDATE_TOKEN}" ]]; then
echo "::warning::Strava refresh token rotated, but STRAVA_SECRET_UPDATE_TOKEN is not configured. Update STRAVA_REFRESH_TOKEN manually."
exit 0
fi
if ! command -v gh >/dev/null 2>&1; then
echo "::warning::gh CLI is unavailable on runner; cannot auto-rotate STRAVA_REFRESH_TOKEN."
exit 0
fi
if printf '%s' "${new_refresh_token}" | GH_TOKEN="${STRAVA_SECRET_UPDATE_TOKEN}" gh secret set STRAVA_REFRESH_TOKEN --repo "${GITHUB_REPOSITORY}" --body -; then
echo "Updated STRAVA_REFRESH_TOKEN from rotated Strava token."
else
echo "::warning::Failed to update STRAVA_REFRESH_TOKEN automatically. Update it manually."
fi
- name: Rotate Garmin token secret (optional)
env:
SYNC_SOURCE: ${{ env.SYNC_SOURCE }}
GARMIN_TOKENS_B64: ${{ secrets.GARMIN_TOKENS_B64 }}
GARMIN_SECRET_UPDATE_TOKEN: ${{ secrets.GARMIN_SECRET_UPDATE_TOKEN }}
run: |
set -euo pipefail
if [[ "${SYNC_SOURCE}" != "garmin" ]]; then
echo "Skipping Garmin token rotation: source=${SYNC_SOURCE}."
exit 0
fi
if [[ ! -d ".garmin_token_store" ]]; then
echo "No local Garmin token store found; skipping token rotation."
exit 0
fi
new_token_store="$(python - <<'PY'
import base64
import io
import os
import zipfile
token_dir = ".garmin_token_store"
if not os.path.isdir(token_dir):
raise SystemExit(0)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for root, _dirs, files in os.walk(token_dir):
for filename in sorted(files):
full_path = os.path.join(root, filename)
rel_path = os.path.relpath(full_path, token_dir)
info = zipfile.ZipInfo(rel_path)
info.date_time = (1980, 1, 1, 0, 0, 0)
info.compress_type = zipfile.ZIP_DEFLATED
with open(full_path, "rb") as handle:
archive.writestr(info, handle.read())
data = buffer.getvalue()
print(base64.b64encode(data).decode("ascii") if data else "")
PY
)"
if [[ -z "${new_token_store}" ]]; then
echo "No refreshed Garmin token store captured; skipping."
exit 0
fi
if [[ "${new_token_store}" == "${GARMIN_TOKENS_B64}" ]]; then
echo "Garmin token store unchanged."
exit 0
fi
if [[ -z "${GARMIN_SECRET_UPDATE_TOKEN}" ]]; then
echo "::warning::Garmin token store changed, but GARMIN_SECRET_UPDATE_TOKEN is not configured. Update GARMIN_TOKENS_B64 manually."
exit 0
fi
if ! command -v gh >/dev/null 2>&1; then
echo "::warning::gh CLI is unavailable on runner; cannot auto-rotate GARMIN_TOKENS_B64."
exit 0
fi
if printf '%s' "${new_token_store}" | GH_TOKEN="${GARMIN_SECRET_UPDATE_TOKEN}" gh secret set GARMIN_TOKENS_B64 --repo "${GITHUB_REPOSITORY}" --body -; then
echo "Updated GARMIN_TOKENS_B64 from refreshed Garmin token store."
else
echo "::warning::Failed to update GARMIN_TOKENS_B64 automatically. Update it manually."
fi
- name: Commit README link update
run: |
set -euo pipefail
if git diff --quiet -- README.md; then
echo "README unchanged."
exit 0
fi
git commit --only -m "docs: set dashboard URL for this fork" -- README.md
if ! git push origin HEAD:${GITHUB_REF_NAME}; then
echo "Warning: unable to push README update; continuing without it."
fi
- name: Publish generated data branch
run: |
set -euo pipefail
branch="${DASHBOARD_DATA_BRANCH}"
data_worktree="${RUNNER_TEMP}/dashboard-data-worktree"
rm -rf "${data_worktree}"
if git ls-remote --exit-code --heads origin "${branch}" >/dev/null; then
git fetch origin "${branch}"
git worktree add "${data_worktree}" "origin/${branch}"
else
git worktree add --detach "${data_worktree}"
pushd "${data_worktree}" >/dev/null
git checkout --orphan "${branch}"
git rm -rf . >/dev/null 2>&1 || true
popd >/dev/null
fi
find "${data_worktree}" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
mkdir -p "${data_worktree}/site"
cp -R data "${data_worktree}/data"
cp site/data.json "${data_worktree}/site/data.json"
pushd "${data_worktree}" >/dev/null
git add -A
if git diff --cached --quiet; then
echo "No dashboard data changes."
popd >/dev/null
git worktree remove "${data_worktree}" --force
exit 0
fi
summary_file="${GITHUB_WORKSPACE}/data/last_sync_summary.txt"
message="Sync data: update dashboard data"
if [ -s "${summary_file}" ]; then
message="$(head -n 1 "${summary_file}")"
fi
git commit -m "${message}"
git push origin HEAD:${branch}
popd >/dev/null
git worktree remove "${data_worktree}" --force