Skip to content

Leave Tracker

Leave Tracker #1066

Workflow file for this run

name: Leave Tracker
# Parses the committed leave-tracker xlsx and publishes the normalized report to the
# `leave-tracker/report` branch, which the Netlify dashboard reads at runtime.
#
# HARDENED FLOW (simple + reproducible): this AUTO-RUNS whenever the source workbook or an
# override file changes on `main` (e.g. an "Add person" PR merges), so the dashboard updates
# with no manual step. The PI is inferred from the xlsx filename, so no inputs are required.
# You can still trigger it by hand (workflow_dispatch) to force a refresh or override the PI.
#
# HOURLY GOOGLE SHEETS SYNC: the team edits leave in a Google Sheet, and for a long time the only
# way that reached the dashboard was someone downloading the .xlsx and committing it by hand — so
# the dashboard silently showed month-old data. When the repo variable LEAVE_SHEET_ID is set, the
# hourly run re-exports that sheet as .xlsx and parses it, and the committed workbook in
# leave/source/ becomes a FALLBACK for when the export is unreachable. Nothing is committed to
# `main` by this job; only the report branch moves.
on:
schedule:
- cron: "0 * * * *" # hourly: re-export the Google Sheet (see LEAVE_SHEET_ID below)
push:
branches: [main]
paths:
- "leave/overrides/**"
- "leave/source/**"
- ".github/workflows/leave-tracker.yml"
workflow_dispatch:
inputs:
xlsx-path:
description: "Path to the leave-tracker xlsx"
type: string
required: false
default: "leave/source/DSE Leave Tracker - 26.4.xlsx"
pi:
description: "Program Increment label (blank = inferred from the xlsx filename)"
type: string
required: false
default: ""
year:
description: "Override inferred year (blank = derive from PI)"
type: string
required: false
default: ""
risk-threshold:
description: "Default fraction of a team OUT to flag (0-1)"
type: string
required: false
default: "0.30"
permissions:
contents: write # publish the report to the leave-tracker/report branch
# One report run at a time; a newer override supersedes an in-flight run.
concurrency:
group: leave-tracker-report
cancel-in-progress: true
env:
DASHBOARD_URL: "https://veda-leave-dashboard.netlify.app"
jobs:
leave-tracker:
runs-on: ubuntu-latest
steps:
- name: "1 · Checkout repository"
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so the report branch can be based/pushed
# Overwrites the workspace copy of the workbook only — never committed to `main`. Keeping the
# same PATH means the PI keeps being inferred from the committed filename, and a failed or
# revoked export degrades to the committed workbook instead of breaking the run.
- name: "1b · Refresh the workbook from Google Sheets"
if: vars.LEAVE_SHEET_ID != ''
env:
SHEET_ID: ${{ vars.LEAVE_SHEET_ID }}
XLSX: ${{ github.event.inputs.xlsx-path || 'leave/source/DSE Leave Tracker - 26.4.xlsx' }}
run: |
set -uo pipefail
URL="https://docs.google.com/spreadsheets/d/${SHEET_ID}/export?format=xlsx"
# A sheet that loses link-sharing returns 200 + an HTML sign-in page, which `curl -f`
# happily accepts — so prove the download is really a zip (xlsx) before trusting it.
if curl -fsSL --max-time 120 -o "$RUNNER_TEMP/sheet.xlsx" "$URL" \
&& python3 -c "import sys,zipfile; sys.exit(0 if zipfile.is_zipfile(sys.argv[1]) else 1)" \
"$RUNNER_TEMP/sheet.xlsx"; then
cp "$RUNNER_TEMP/sheet.xlsx" "$XLSX"
echo "Refreshed from Google Sheets: $(wc -c < "$XLSX") bytes."
else
echo "::warning::Google Sheets export failed or was not an .xlsx — using the committed workbook."
fi
- name: "2 · Parse the leave tracker (reusable action)"
id: run
uses: ./leave
with:
# On `push` github.event.inputs.* are empty strings, so fall back to defaults here
# (an empty xlsx-path would otherwise fail the file check). pi stays blank on purpose —
# the generator infers it from the xlsx filename.
xlsx-path: ${{ github.event.inputs.xlsx-path || 'leave/source/DSE Leave Tracker - 26.4.xlsx' }}
pi: ${{ github.event.inputs.pi }}
year: ${{ github.event.inputs.year }}
risk-threshold: ${{ github.event.inputs.risk-threshold || '0.30' }}
out-dir: reports
- name: "3 · Job summary"
run: |
echo "people=${{ steps.run.outputs.people }} teams=${{ steps.run.outputs.teams }} leave-days=${{ steps.run.outputs.leave-days }}"
{
echo ""
echo "🗓️ **Leave dashboard →** ${DASHBOARD_URL}"
echo "_${{ steps.run.outputs.people }} people · ${{ steps.run.outputs.teams }} teams · ${{ steps.run.outputs.leave-days }} out-days_"
} >> "$GITHUB_STEP_SUMMARY"
- name: "4 · Upload report artifact"
uses: actions/upload-artifact@v4
with:
name: leave-tracker-report
path: reports/
- name: "5 · Publish report to the leave-tracker/report branch (accumulate by PI)"
run: |
BR="leave-tracker/report"
mkdir -p "$RUNNER_TEMP/out" && cp -R reports/. "$RUNNER_TEMP/out/" # stash before switching branches
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin "$BR" || true
if git rev-parse --verify --quiet "origin/$BR"; then
git checkout -f -B "$BR" "origin/$BR" # continue history: prior PIs persist
else
git checkout -f -B "$BR"
fi
mkdir -p reports
# Keep only slugged leave reports + the index (drop main's other samples / fixed names).
find reports -maxdepth 1 -type f \
! -name 'leaves_*.csv' ! -name 'leaves_*.json' ! -name 'leave_coverage_*.json' \
! -name 'index.json' -delete 2>/dev/null || true
# Copy in THIS run's slugged files (new content wins for the same slug), incl. the manifest.
MAN="$RUNNER_TEMP/out/leave_manifest.json"
cp "$MAN" "reports/leave_manifest.json"
for k in csv json coverage; do
f="$(jq -r ".files.$k" "$MAN")"; cp "$RUNNER_TEMP/out/$f" "reports/$f"
done
# Upsert this run's entry into reports/index.json (dedupe by slug, newest first).
[ -f reports/index.json ] || echo '[]' > reports/index.json
ENTRY="$(jq -c '{slug,pi,year,generated,files,stats}' "$MAN")"
jq --argjson e "$ENTRY" '(map(select(.slug != $e.slug))) + [$e] | sort_by(.generated) | reverse' \
reports/index.json > reports/index.json.tmp && mv reports/index.json.tmp reports/index.json
git add -f reports
# The manifest carries a fresh `generated` timestamp on every run, so once this job runs
# hourly an idle run would still "change" a file and commit — 24 empty commits a day. If
# the ONLY staged file is the manifest and the ONLY differing lines in it are "generated",
# nothing about the leave data actually moved: leave the branch alone.
CHANGED="$(git diff --cached --name-only)"
if [ "$CHANGED" = "reports/leave_manifest.json" ] \
&& ! git diff --cached -U0 -- reports/leave_manifest.json \
| grep -E '^[+-][^+-]' | grep -qv '"generated"'; then
echo "No data change for $BR — only the generated timestamp moved."
elif [ -z "$CHANGED" ]; then
echo "No report changes for $BR."
else
git commit -m "Leave tracker report — $(jq -r .slug "$MAN") (run ${{ github.run_id }})"
git push origin "$BR"
echo "Report pushed to branch: $BR"
fi
- name: "6 · Restore the default checkout"
if: always()
run: git checkout -f "${{ github.sha }}" 2>/dev/null || git checkout -f main