Skip to content
Merged
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
114 changes: 114 additions & 0 deletions .github/workflows/build-pdf.yml
Original file line number Diff line number Diff line change
@@ -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 [email protected] --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 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 scripts/zenodo_publish.py security-in-age-of-ai
1 change: 1 addition & 0 deletions scripts/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
requests==2.32.3
237 changes: 237 additions & 0 deletions scripts/zenodo_publish.py
Original file line number Diff line number Diff line change
@@ -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:<slug>``.

Driven by a per-deck ``zenodo.json`` metadata file next to the deck
sources. Run as::

python scripts/zenodo_publish.py <deck-dir>

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>")

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))
22 changes: 22 additions & 0 deletions security-in-age-of-ai/zenodo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"slug": "security-in-age-of-ai",
"community": "uw-ssec",
"title": "Security in the Age of AI",
"upload_type": "presentation",
"description": "<p>Open Source Supply Chain Security: Threats, Mitigations &amp; Hardened Workflows.</p><p>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.</p>",
"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"
}