diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 0000000..d80d9fc --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1 @@ +requests==2.32.3 diff --git a/.github/scripts/zenodo_publish.py b/.github/scripts/zenodo_publish.py new file mode 100644 index 0000000..16b28b3 --- /dev/null +++ b/.github/scripts/zenodo_publish.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Publish a presentation PDF to Zenodo. + +On first run, creates a new deposition in the ``uw-ssec`` community. +On subsequent runs, opens a new version of the existing record and +replaces its file. The existing record is found by searching the +authenticated user's depositions for a unique keyword tag of the form +``uw-ssec-deck:``. + +Driven by a per-deck ``zenodo.json`` metadata file next to the deck +sources. Run as:: + + python scripts/zenodo_publish.py + +Required environment: + ZENODO_TOKEN Personal access token with ``deposit:write`` and + ``deposit:actions`` scopes. + +Optional environment: + ZENODO_SANDBOX When set to ``true``/``1``, target + sandbox.zenodo.org instead of zenodo.org. + +Versioning is CalVer (``YYYY.MM.DD``, UTC). When more than one publish +happens on the same day, the version becomes ``YYYY.MM.DD.N`` with +``N`` incremented from the previous record's version. +""" +from __future__ import annotations + +import datetime +import json +import os +import sys +from pathlib import Path + +import requests + + +DEFAULT_COMMUNITY = "uw-ssec" +KEYWORD_PREFIX = "uw-ssec-deck" + + +def env(name: str, default: str | None = None) -> str | None: + value = os.environ.get(name, default) + if value is not None and value != "": + return value + return default + + +def required_env(name: str) -> str: + value = env(name) + if not value: + sys.exit(f"missing required env var: {name}") + return value + + +def truthy(value: str | None) -> bool: + return (value or "").lower() in {"1", "true", "yes", "on"} + + +class Zenodo: + def __init__(self, token: str, sandbox: bool = False) -> None: + host = "sandbox.zenodo.org" if sandbox else "zenodo.org" + self.api = f"https://{host}/api" + self.session = requests.Session() + self.session.headers["Authorization"] = f"Bearer {token}" + + def _call(self, method: str, url: str, **kwargs) -> requests.Response: + kwargs.setdefault("timeout", 60) + r = self.session.request(method, url, **kwargs) + if not r.ok: + sys.stderr.write( + f"\nZenodo {method} {url} failed: {r.status_code}\n{r.text}\n" + ) + r.raise_for_status() + return r + + def find_existing(self, tag: str) -> dict | None: + """Return the newest deposition matching the keyword tag, or None.""" + r = self._call( + "GET", + f"{self.api}/deposit/depositions", + params={"q": f'keywords:"{tag}"', "size": 100, "sort": "mostrecent"}, + ) + matches = r.json() + return matches[0] if matches else None + + def new_record(self) -> dict: + r = self._call("POST", f"{self.api}/deposit/depositions", json={}) + return r.json() + + def new_version(self, deposit_id: int) -> dict: + """Open a new draft version of a published record.""" + r = self._call( + "POST", + f"{self.api}/deposit/depositions/{deposit_id}/actions/newversion", + ) + latest_draft_url = r.json()["links"]["latest_draft"] + return self._call("GET", latest_draft_url).json() + + def replace_files(self, deposit: dict, file_path: Path) -> None: + for f in deposit.get("files", []): + self._call( + "DELETE", + f"{self.api}/deposit/depositions/{deposit['id']}/files/{f['id']}", + ) + bucket = deposit["links"]["bucket"] + with file_path.open("rb") as fh: + self._call( + "PUT", + f"{bucket}/{file_path.name}", + data=fh, + timeout=600, + ) + + def set_metadata(self, deposit_id: int, metadata: dict) -> dict: + r = self._call( + "PUT", + f"{self.api}/deposit/depositions/{deposit_id}", + json={"metadata": metadata}, + headers={"Content-Type": "application/json"}, + ) + return r.json() + + def publish(self, deposit_id: int) -> dict: + r = self._call( + "POST", + f"{self.api}/deposit/depositions/{deposit_id}/actions/publish", + ) + return r.json() + + +def load_deck_metadata(deck_dir: Path) -> dict: + cfg_path = deck_dir / "zenodo.json" + if not cfg_path.is_file(): + sys.exit(f"missing {cfg_path}") + return json.loads(cfg_path.read_text()) + + +def next_calver(existing: dict | None) -> str: + """Compute today's CalVer, bumping ``.N`` if it collides with the + previous record's version (e.g. multiple publishes per day).""" + today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y.%m.%d") + if existing is None: + return today + prev = ((existing.get("metadata") or {}).get("version") or "").strip() + if prev == today: + return f"{today}.2" + if prev.startswith(f"{today}."): + try: + n = int(prev.rsplit(".", 1)[-1]) + except ValueError: + return f"{today}.2" + return f"{today}.{n + 1}" + return today + + +def build_metadata(deck: dict, slug: str, version: str | None) -> dict: + keywords = list(deck.get("keywords", [])) + tag = f"{KEYWORD_PREFIX}:{slug}" + if tag not in keywords: + keywords.append(tag) + + community = deck.get("community", DEFAULT_COMMUNITY) + metadata: dict = { + "upload_type": deck.get("upload_type", "presentation"), + "title": deck["title"], + "description": deck["description"], + "creators": deck["creators"], + "communities": [{"identifier": community}], + "keywords": keywords, + "access_right": "open", + "license": deck.get("license", "cc-by-4.0"), + } + if "publication_date" in deck: + metadata["publication_date"] = deck["publication_date"] + if version: + metadata["version"] = version + return metadata + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + sys.exit("usage: zenodo_publish.py ") + + deck_dir = Path(argv[1]).resolve() + if not deck_dir.is_dir(): + sys.exit(f"not a directory: {deck_dir}") + + token = required_env("ZENODO_TOKEN") + sandbox = truthy(env("ZENODO_SANDBOX")) + + deck = load_deck_metadata(deck_dir) + slug = deck.get("slug") or deck_dir.name + pdf_name = deck.get("pdf") or f"{slug}.pdf" + pdf_path = deck_dir / pdf_name + if not pdf_path.is_file(): + sys.exit(f"PDF not found: {pdf_path}") + + zen = Zenodo(token, sandbox=sandbox) + tag = f"{KEYWORD_PREFIX}:{slug}" + existing = zen.find_existing(tag) + + version = next_calver(existing) + print(f"version: {version}") + metadata = build_metadata(deck, slug, version) + + if existing is None: + print(f"no existing record for {tag}; creating new deposition") + deposit = zen.new_record() + else: + print( + f"found existing deposition {existing['id']}; opening new version" + ) + deposit = zen.new_version(existing["id"]) + + deposit = zen.set_metadata(deposit["id"], metadata) + zen.replace_files(deposit, pdf_path) + published = zen.publish(deposit["id"]) + + record_url = published.get("links", {}).get("record_html") or published.get( + "links", {} + ).get("html") + doi = published.get("doi") or published.get("metadata", {}).get("doi") + print(f"published: {record_url}") + print(f"DOI: {doi}") + + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as fh: + fh.write(f"## Zenodo publish — {deck['title']}\n\n") + fh.write(f"- Record: <{record_url}>\n") + fh.write(f"- DOI: `{doi}`\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/build-pdf.yml b/.github/workflows/build-pdf.yml new file mode 100644 index 0000000..6b899f6 --- /dev/null +++ b/.github/workflows/build-pdf.yml @@ -0,0 +1,114 @@ +name: Build Presentation PDFs + +on: + pull_request: + paths: + - 'security-in-age-of-ai/**' + - '.github/workflows/build-pdf.yml' + push: + branches: [main, staging] + paths: + - 'security-in-age-of-ai/**' + - '.github/workflows/build-pdf.yml' + workflow_dispatch: + +# Practice what the deck preaches: deny by default, opt in per job. +permissions: {} + +jobs: + # ── Build ──────────────────────────────────────────────────────── + # Runs on every PR and push. No write access, no secrets — if a + # malicious PR ran during this job, it would find nothing useful. + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + - name: Enable pnpm via Corepack + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + # pnpm needs an explicit global bin dir on PATH for `pnpm add -g` + PNPM_HOME="$HOME/.local/share/pnpm" + mkdir -p "$PNPM_HOME" + pnpm config set global-bin-dir "$PNPM_HOME" + echo "PNPM_HOME=$PNPM_HOME" >> "$GITHUB_ENV" + echo "$PNPM_HOME" >> "$GITHUB_PATH" + pnpm --version + + - name: Install decktape + # Pin to commit SHA, not a tag - tags on npm/GitHub can be retagged + run: pnpm add -g github:astefanutti/decktape#6735cec96dad6b32ee43d04f858b9794081cb866 # v3.12.0 + + - name: Serve repository over HTTP + # decktape's reveal.js automation needs http://, not file:// + run: pnpm dlx http-server . -p 8000 -s & + + - name: Wait for server + run: pnpm dlx wait-on http://localhost:8000 + + - name: Build security-in-age-of-ai PDF + # --chrome-arg=--no-sandbox: GitHub-hosted Ubuntu runners can't use + # Chrome's SUID sandbox; safe inside this throwaway runner. + run: | + decktape reveal \ + --chrome-arg=--no-sandbox \ + --size 1280x720 \ + --load-pause 500 \ + http://localhost:8000/security-in-age-of-ai/index.html \ + security-in-age-of-ai/security-in-age-of-ai.pdf + + - name: Upload PDF as workflow artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: security-in-age-of-ai-pdf + path: security-in-age-of-ai/security-in-age-of-ai.pdf + + # ── Publish ────────────────────────────────────────────────────── + # Push to main → production zenodo.org + # Push to staging → sandbox.zenodo.org (for testing the full flow) + # First run on each target creates a new record; later runs publish + # a new version of the existing record. + publish: + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Install Python dependencies + run: pip install -r .github/scripts/requirements.txt + + - name: Download PDF artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: security-in-age-of-ai-pdf + path: security-in-age-of-ai/ + + - name: Publish to Zenodo (uw-ssec community) + env: + # staging → sandbox, main → production. Each Zenodo host has + # its own account and its own token. + ZENODO_TOKEN: ${{ github.ref == 'refs/heads/staging' && secrets.ZENODO_SANDBOX_TOKEN || secrets.ZENODO_TOKEN }} + ZENODO_SANDBOX: ${{ github.ref == 'refs/heads/staging' && 'true' || 'false' }} + run: python .github/scripts/zenodo_publish.py security-in-age-of-ai diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..61a6b96 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,127 @@ +# AGENTS.md + +Operating rules for AI agents working in this repo. Read this before doing anything. Cross-reference `README.md` for the human-facing version. + +## What this repo is + +Reveal.js presentations + a GitHub Actions pipeline that renders each deck to PDF and deposits it to Zenodo under the [`uw-ssec`](https://zenodo.org/communities/uw-ssec/) community. + +- `staging` → `sandbox.zenodo.org` (throwaway records) +- `main` → `zenodo.org` (real DOIs, permanent) +- PRs render but never publish. + +## Core principles + +- **Think before acting.** Read the surrounding file/slide first. Mirror its conventions instead of inventing new ones. +- **Make surgical changes.** Touch only what the task requires. Don't reformat, rename, or "tidy" adjacent code. +- **Prefer the boring option.** Inline `style="…"` everywhere? Keep it. SHA-pinned actions? Keep it. The deck preaches these patterns — don't break them. +- **State what changed and why** in your final reply. Don't bury edits in long preambles. + +## Project rules + +- **Branch off `staging`.** PRs target `staging`. After staging publishes successfully to sandbox, the user opens a separate PR `staging` → `main`. +- **SHA-pin every `uses:` action** with a 40-char commit SHA and a `# vX.Y.Z` tag comment. The deck literally argues for this; the workflow has to model it. +- **Workflow permissions are deny-by-default.** `permissions: {}` at the top, each job opts in. `build` is `contents: read`; `publish` is `contents: read` and reads secrets only. +- **Versioning is CalVer (`YYYY.MM.DD`).** Same-day re-publishes bump `.N`. Do not switch to git-SHA versioning. +- **Existing Zenodo records are found by keyword tag `uw-ssec-deck:`.** Changing a deck's slug orphans its DOI history. Don't change slugs casually. +- **Reveal.js aspect is `1280×720`.** Match the workflow's `--size`. +- **Preserve slide `id` attributes** when restructuring — they're deep links. +- **Footnote citations stay.** Every slide ending in a `.footnote` block keeps it. Trim wording, don't drop sources. +- **Fix slide overflow inline**, on the offending element (font-size / line-height / shorter wording). Don't edit global CSS for one slide's problem. +- **Path filter list stays in sync** with the deck directories the workflow handles (`/**`). + +## Never do + +- ❌ **Never add AI attribution to commit messages or PR bodies.** No `Co-Authored-By: Claude …`, no `🤖 Generated with Claude Code`. The deck has an explicit AI Attribution slide; commit-level disclosure is duplicative noise the user has already rejected. +- ❌ **Never push directly to `main`.** Promote via PR from `staging`. +- ❌ **Never make the `publish` job fire on PRs or feature branches.** That would mint real (or sandbox) Zenodo records from unreviewed code. +- ❌ **Never run the publish script against production from a local machine** without explicit go-ahead — Zenodo production records are permanent. +- ❌ **Never replace SHA-pins with tag refs** (`actions/checkout@v4`). Always full-SHA + comment. +- ❌ **Never bump `decktape`, `pnpm`, or Python without asking.** Versions are pinned for a reason. +- ❌ **Never delete a deck's `zenodo.json` or change its `slug`/`community`** without explicit confirmation. +- ❌ **Never invent new CSS classes** to do what `card`, `card-red`, `card-blue`, `card-green`, `check-item`, `footnote`, etc. already do. +- ❌ **Never re-Read a file** you just edited to "verify" — the harness errors on failed edits. +- ❌ **Never use the Read tool on a skill file.** Invoke skills via the `Skill` tool. + +## Patterns to match + +**Slide hardening — fix overflow on the offending pre/list, not global CSS:** + +```html + + + + +
+``` + +**Workflow actions — always SHA + comment:** + +```yaml +# ❌ +- uses: actions/checkout@v4 + +# ✅ +- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +``` + +**Untrusted event data — bind via env, not interpolation:** + +```yaml +# ❌ +- run: echo "${{ github.event.pull_request.title }}" + +# ✅ +- env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: echo "$PR_TITLE" +``` + +**Headless Chrome on GitHub runners — always pass --no-sandbox:** + +```bash +# ✅ +decktape reveal --chrome-arg=--no-sandbox … +``` + +## Adding a new deck + +1. Create `/index.html` plus assets. +2. Add a gallery card to root `index.html`. +3. Add `/zenodo.json` with: `slug`, `community: "uw-ssec"`, `title`, `upload_type: "presentation"`, `description` (HTML ok), `creators` with ORCIDs, `keywords`, `license`, `pdf`. +4. Extend `.github/workflows/build-pdf.yml`: add a build step that renders this deck and a publish step that runs `python .github/scripts/zenodo_publish.py `. Update the path filter. +5. Open a PR to `staging`. Verify the sandbox record. Then user opens `staging` → `main`. + +## Confirm before doing + +- Any change that would create or update a real Zenodo record. +- Changing a deck's `slug`, `community`, `creators`, or ORCIDs. +- Moving `.github/scripts/` or top-level directories. +- Switching package managers, Python versions, or pinned tool versions. + +## Local commands + +```bash +# Serve repo for local browsing / decktape +python3 -m http.server 8000 + +# Render a deck to PDF locally +npx decktape reveal --chrome-arg=--no-sandbox \ + --size 1280x720 --load-pause 500 \ + http://localhost:8000//index.html \ + /.pdf + +# Dry-run the publish script against sandbox +ZENODO_TOKEN='' ZENODO_SANDBOX=true \ + python .github/scripts/zenodo_publish.py +``` + +Sandbox records can be deleted from the UI; production records cannot. Default to sandbox when in doubt. + +## Tech stack + +- Reveal.js 5.1.0 (loaded from `cdn.jsdelivr.net`). +- decktape 3.12.0 (pinned by commit SHA in the workflow). +- pnpm 9.15.0 via Corepack. +- Python 3.12 + `requests==2.32.3` for the publish script. +- GitHub-hosted `ubuntu-latest` runners. diff --git a/README.md b/README.md index 29e4603..6a1e5a6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,103 @@ -# presentations -Repository for public event presentations +# UW SSEC Presentations + +Public event presentations from the University of Washington Scientific Software Engineering Center (SSEC). Each deck is a self-contained Reveal.js site that is automatically rendered to PDF and deposited to [Zenodo](https://zenodo.org/communities/uw-ssec/) when merged to `main`. + +The live gallery is served by GitHub Pages at the repository root (`index.html`); individual decks live in their own directories. + +## Repository layout + +``` +. +├── index.html # landing-page gallery +├── / # one directory per presentation +│ ├── index.html # Reveal.js entry point +│ ├── styles.css # deck-specific styles (if any) +│ └── zenodo.json # metadata used by the publish script +└── .github/ + ├── workflows/ + │ └── build-pdf.yml # PDF build + Zenodo publish workflow + └── scripts/ + ├── zenodo_publish.py # publish driver + └── requirements.txt +``` + +## Branch model + +| Branch | Zenodo target | Purpose | +| --------- | ------------------------------ | ------------------------------------------- | +| `main` | production (`zenodo.org`) | Permanent records with real DOIs | +| `staging` | sandbox (`sandbox.zenodo.org`) | Throwaway records for end-to-end validation | + +Day-to-day flow: feature branch → PR to `staging` → review & merge → check the sandbox record → PR `staging` → `main` → production deposit. + +Pull requests run the build job (rendering the PDF as an artifact) but never publish, so you can verify the deck looks right before either base branch sees it. + +## Adding a new presentation + +1. **Create the deck directory** at the repository root using a stable kebab-case slug (this slug appears in the URL and is the unique key for the Zenodo record). + ``` + / + index.html + styles.css # if needed + ...assets + ``` +2. **Add it to the landing gallery** by editing `index.html` (copy an existing `
  • ` block and update tags, title, link, and event metadata). +3. **Add `zenodo.json`** next to the deck. Minimum fields: + ```jsonc + { + "slug": "my-deck-slug", + "community": "uw-ssec", + "title": "Talk Title", + "upload_type": "presentation", + "description": "

    HTML description shown on the Zenodo record.

    ", + "creators": [ + { + "name": "Last, First", + "affiliation": "University of Washington Scientific Software Engineering Center", + "orcid": "0000-0000-0000-0000" + } + ], + "keywords": ["topic-1", "topic-2"], + "license": "cc-by-4.0", + "pdf": "my-deck-slug.pdf" + } + ``` +4. **Wire the deck into the workflow.** Open `.github/workflows/build-pdf.yml` and add another build step pointing at the new deck's `index.html`, plus a corresponding publish step (or generalize the existing steps if you're adding several at once). +5. **Open a PR against `staging`.** The PR run will attach the rendered PDF as a workflow artifact you can download from the run page. +6. **Merge to `staging`.** The publish job runs against `sandbox.zenodo.org`. Verify the record looks right. +7. **PR `staging` → `main`.** Merge to produce the real Zenodo record under the [uw-ssec community](https://zenodo.org/communities/uw-ssec/). + +## Developing a deck locally + +Open the deck's `index.html` in a browser. Most things work from `file://`, but features that fetch (e.g. Reveal's `?print-pdf` mode) need a local server: + +```bash +python3 -m http.server 8000 +# then open http://localhost:8000// +``` + +To preview the PDF the workflow would produce: + +```bash +npx decktape reveal --chrome-arg=--no-sandbox \ + --size 1280x720 --load-pause 500 \ + http://localhost:8000//index.html \ + /.pdf +``` + +## Required repository secrets + +| Secret | Used by branch | Where to generate | +| ----------------------- | -------------- | ---------------------------------------------------------------------------------- | +| `ZENODO_TOKEN` | `main` | | +| `ZENODO_SANDBOX_TOKEN` | `staging` | | + +Both tokens need the `deposit:write` and `deposit:actions` scopes. Sandbox and production Zenodo are independent accounts. + +## Versioning and idempotency + +The publish script tags each Zenodo record with `version: YYYY.MM.DD` (UTC) at publish time. Re-publishing the same day bumps the suffix (`YYYY.MM.DD.2`, `.3`, …) so versions remain unique. The script finds the existing record for a deck by searching the authenticated user's depositions for a keyword tag of the form `uw-ssec-deck:` — keep this in mind if you change a deck's slug. + +## License + +See [`LICENSE`](LICENSE) for the repository license. Each Zenodo record is published under the license declared in its `zenodo.json` (`cc-by-4.0` by default). diff --git a/security-in-age-of-ai/zenodo.json b/security-in-age-of-ai/zenodo.json new file mode 100644 index 0000000..a4afab7 --- /dev/null +++ b/security-in-age-of-ai/zenodo.json @@ -0,0 +1,22 @@ +{ + "slug": "security-in-age-of-ai", + "community": "uw-ssec", + "title": "Security in the Age of AI", + "upload_type": "presentation", + "description": "

    Open Source Supply Chain Security: Threats, Mitigations & Hardened Workflows.

    Slides from the UW Scientific Software Engineering Center (SSEC) Research Software Engineering Meetup. Covers GitHub Actions attack classes, recent supply-chain incidents (tj-actions, Shai-Hulud, Ultralytics, Copy Fail), the convergence of frontier AI and security disclosure, and hardened consumer and publisher CI/CD workflows for npm and PyPI.

    ", + "creators": [ + {"name": "Setiawan, Landung", "affiliation": "University of Washington Scientific Software Engineering Center", "orcid": "0000-0002-1624-2667"}, + {"name": "Core, Cordero", "affiliation": "University of Washington Scientific Software Engineering Center", "orcid": "0000-0002-3531-3221"} + ], + "keywords": [ + "security", + "supply-chain", + "github-actions", + "ai", + "open-source", + "ci-cd", + "research-software-engineering" + ], + "license": "cc-by-4.0", + "pdf": "security-in-age-of-ai.pdf" +}