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
3 changes: 1 addition & 2 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
* @pythonhk/event-organizers
/.github/ @pythonhk/event-organizers
/protocol/ @pythonhk/event-organizers
/scripts/ @pythonhk/event-organizers
/event/ @pythonhk/event-organizers
/registry/ @pythonhk/event-organizers
48 changes: 48 additions & 0 deletions .github/actions/eventctl/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Pinned eventctl
description: Resolve the checksum-pinned eventctl release for trusted workflows.

inputs:
test-bin:
description: E2E-only executable path supplied by the act fixture.
required: false

outputs:
path:
description: Absolute path to the verified eventctl executable.
value: ${{ steps.resolve.outputs.path }}

runs:
using: composite
steps:
- id: resolve
shell: bash
env:
EVENTCTL_TEST_BIN: ${{ inputs.test-bin }}
run: |
set -Eeuo pipefail
if [[ -n "$EVENTCTL_TEST_BIN" ]]; then
test -x "$EVENTCTL_TEST_BIN"
printf 'path=%s\n' "$EVENTCTL_TEST_BIN" >> "$GITHUB_OUTPUT"
exit 0
fi
lock="$GITHUB_WORKSPACE/tools/eventctl.lock.json"
version=$(jq -er '.version' "$lock")
repository=$(jq -er '.repository' "$lock")
test "$version" != UNRELEASED
asset=$(jq -er '.assets["linux-amd64"].name' "$lock")
archive_sha256=$(jq -er '.assets["linux-amd64"].archive_sha256' "$lock")
binary_sha256=$(jq -er '.assets["linux-amd64"].binary_sha256' "$lock")
directory="$RUNNER_TEMP/eventctl-${version}"
archive="$directory/$asset"
binary="$directory/eventctl"
mkdir -p "$directory"
curl --fail --location --silent --show-error \
"https://github.com/${repository}/releases/download/v${version}/${asset}" \
--output "$archive"
printf '%s %s\n' "$archive_sha256" "$archive" | sha256sum --check --status
tar -xzf "$archive" -C "$directory"
test -x "$binary"
printf '%s %s\n' "$binary_sha256" "$binary" | sha256sum --check --status
printf 'path=%s\n' "$binary" >> "$GITHUB_OUTPUT"
79 changes: 70 additions & 9 deletions .github/workflows/admin-plan.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
name: Organizer state plan
name: Organizer verification plan

on:
workflow_dispatch:
inputs:
operation:
description: Prepare a reviewed registry PR; this workflow never merges it
description: Verify one merged request without writing registry state.
required: true
type: choice
options: [activate-team, reserve-submission]
options: [register, activate-team]
request_path:
description: Path to the merged request on main
description: Registration file or team request directory on main.
required: true
type: string
source_times:
description: JSON trusted times; see README.
required: true
type: string

Expand All @@ -18,7 +22,7 @@ permissions:

jobs:
plan:
name: organizer / state-plan
name: organizer / verification-plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
environment: organizer-administration
Expand All @@ -28,11 +32,68 @@ jobs:
with:
ref: main
persist-credentials: false
- name: Explain organizer merge boundary

- name: Check out protected registry
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: registry
path: registry-branch
persist-credentials: false

- id: eventctl
name: Resolve pinned eventctl
uses: ./.github/actions/eventctl
with:
test-bin: ${{ env.EVENTCTL_E2E_BIN }}

- name: Produce a no-write cryptographic plan
env:
EVENTCTL: ${{ steps.eventctl.outputs.path }}
OPERATION: ${{ inputs.operation }}
REQUEST_PATH: ${{ inputs.request_path }}
SOURCE_TIMES: ${{ inputs.source_times }}
shell: bash
run: |
set -Eeuo pipefail
test -n '${{ inputs.request_path }}'
printf '%s\n' 'Plan generated from trusted main.'
source_times="$RUNNER_TEMP/source-times.json"
printf '%s' "$SOURCE_TIMES" > "$source_times"
jq -e . "$source_times" >/dev/null
case "$OPERATION" in
register)
[[ "$REQUEST_PATH" =~ ^requests/users/[1-9][0-9]{0,19}\.json$ ]]
actor_id=${REQUEST_PATH#requests/users/}
actor_id=${actor_id%.json}
source_time=$(jq -er '.request' "$source_times")
"$EVENTCTL" identity verify \
--event event/binding.json \
--input "$REQUEST_PATH" \
--expect-actor-id "$actor_id" \
--source-time "$source_time" \
--output "$RUNNER_TEMP/verified-identity.json"
cat "$RUNNER_TEMP/verified-identity.json"
;;
activate-team)
[[ "$REQUEST_PATH" =~ ^requests/teams/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]]
proposal="$REQUEST_PATH/proposal.json"
test -f "$proposal"
mapfile -t consents < <(find "$REQUEST_PATH/proofs" -type f -name '*.json' -print | LC_ALL=C sort)
test "${#consents[@]}" -gt 0
arguments=()
for consent in "${consents[@]}"; do
actor_id=$(jq -er '.actor_id' "$consent")
source_time=$(jq -er --arg actor "$actor_id" '.consents[$actor]' "$source_times")
arguments+=(--consent "$consent" --consent-source-time "$actor_id=$source_time")
done
"$EVENTCTL" team verify \
--event event/binding.json \
--registry registry-branch/registry/state.json \
--proposal "$proposal" \
--proposal-source-time "$(jq -er '.proposal' "$source_times")" \
"${arguments[@]}" \
--output "$RUNNER_TEMP/verified-team.json"
cat "$RUNNER_TEMP/verified-team.json"
;;
*) exit 2 ;;
esac
git diff --exit-code -- .
printf '%s\n' 'No registry write was performed.'
printf '%s\n' 'Review and merge an admin/* PR against registry.'
55 changes: 33 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,41 +1,52 @@
name: Event CI
name: Event E2E

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions: {}
permissions:
contents: read

jobs:
request-shape:
name: event / request-shape
e2e:
name: event / act-e2e
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 30
steps:
- name: Check out trusted revision
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Validate repository files
shell: bash
run: bash tests/run-security.sh

hostile-inputs:
name: event / hostile-inputs
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Check out trusted revision
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up mise
uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
with:
persist-credentials: false
- name: Run shell syntax and hostile-input checks
shell: bash
cache: false

- name: Install act
run: |
go install github.com/nektos/act@v0.2.88
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Prepare the pinned checkout action for act
env:
CHECKOUT_ACTION_SHA: 11bd71901bbe5b1630ceea73d27597364c9af683
run: |
set -Eeuo pipefail
bash -n scripts/lib/common.sh scripts/actions/*.sh scripts/admin/*.sh scripts/participant/*.sh scripts/scoring/*.sh tools/install-eventctl.sh
bash tests/run-security.sh
cache="$HOME/.cache/act/actions-checkout.git"
rm -rf "$cache"
mkdir -p "$(dirname "$cache")"
# The pytest fixture clones this repository locally to serve the
# pinned action to act. Use a fresh complete shallow bare repository
# so no promisor or stale objects can leak into that local transfer.
git init --bare "$cache"
git -C "$cache" remote add origin https://github.com/actions/checkout.git
git -C "$cache" fetch --no-filter --depth=1 origin \
"$CHECKOUT_ACTION_SHA:refs/heads/pinned"
- name: Run the act-backed E2E suite
env:
ACT_CONTAINER_ARCH: linux/amd64
run: mise run test
45 changes: 30 additions & 15 deletions .github/workflows/registration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: Participant registration validation
on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- 'requests/users/*.json'

permissions:
contents: read
Expand All @@ -20,28 +22,41 @@ jobs:
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Validate exact registration request

- id: eventctl
name: Resolve pinned eventctl
uses: ./.github/actions/eventctl
with:
test-bin: ${{ env.EVENTCTL_E2E_BIN }}

- name: Verify the event-bound registration
env:
EVENTCTL: ${{ steps.eventctl.outputs.path }}
GH_TOKEN: ${{ github.token }}
EVENT_REPOSITORY: ${{ github.repository }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
EVENT_AUTHOR_ID: ${{ github.event.pull_request.user.id }}
EVENT_SOURCE_TIME: ${{ github.event.pull_request.created_at }}
shell: bash
run: |
set -Eeuo pipefail
. scripts/lib/common.sh
event_require_no_secrets_for_pr
paths="$RUNNER_TEMP/changed-paths"
event_changed_paths "$EVENT_REPOSITORY" "$EVENT_PR_NUMBER" "$paths"
count=$(wc -l < "$paths" | tr -d ' ')
[[ "$count" == 1 ]] || event_die "registration PR must change exactly one file, got $count"
request_path=$(grep -E '^requests/users/[1-9][0-9]{0,19}\.json$' "$paths" || true)
[[ -n "$request_path" ]] || event_die 'registration request path is invalid'
path_actor=${request_path#requests/users/}
path_actor=${path_actor%.json}
[[ "$path_actor" == "$EVENT_AUTHOR_ID" ]] || event_die 'registration filename must equal authenticated actor ID'
event_fetch_pr_blob "$EVENT_REPOSITORY" "$EVENT_HEAD_SHA" "$request_path" "$RUNNER_TEMP/registration.json"
bash scripts/actions/registration.sh \
--request "$RUNNER_TEMP/registration.json" \
--actual-author "$EVENT_AUTHOR_ID"
gh api --paginate \
-H 'Accept: application/vnd.github+json' \
"repos/${EVENT_REPOSITORY}/pulls/${EVENT_PR_NUMBER}/files?per_page=100" \
--jq '.[].filename' > "$paths"
test "$(wc -l < "$paths" | tr -d ' ')" = 1
request_path=$(cat "$paths")
[[ "$request_path" =~ ^requests/users/([1-9][0-9]{0,19})\.json$ ]]
test "${BASH_REMATCH[1]}" = "$EVENT_AUTHOR_ID"
gh api -H 'Accept: application/vnd.github.raw+json' \
"repos/${EVENT_REPOSITORY}/contents/${request_path}?ref=${EVENT_HEAD_SHA}" \
> "$RUNNER_TEMP/request.json"
"$EVENTCTL" identity verify \
--event event/binding.json \
--input "$RUNNER_TEMP/request.json" \
--expect-actor-id "$EVENT_AUTHOR_ID" \
--source-time "$EVENT_SOURCE_TIME" \
--output "$RUNNER_TEMP/verified-identity.json"
printf '%s\n' 'registration request is signed, event-bound, and actor-bound'
49 changes: 18 additions & 31 deletions .github/workflows/registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,24 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Validate one authoritative state and derived views

- name: Check out trusted public event policy
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
path: main
persist-credentials: false

- id: eventctl
name: Resolve pinned eventctl
uses: ./.github/actions/eventctl
with:
test-bin: ${{ env.EVENTCTL_E2E_BIN }}

- name: Validate one authoritative registry document
env:
EVENTCTL: ${{ steps.eventctl.outputs.path }}
shell: bash
run: |
set -Eeuo pipefail
state=registry/state.json
test -f "$state" && test ! -L "$state"
jq -e -cS . "$state" > "$RUNNER_TEMP/state.canonical"
cmp -s "$state" "$RUNNER_TEMP/state.canonical"
jq -e '
.schema == "pythonhk.registry-state/v2"
and (.event_id | type == "string")
and (.phase | type == "string")
and (.enabled | type == "boolean")
and (.disabled_reason | type == "string")
and (.revision | type == "number" and floor == . and . >= 0)
and (.users | type == "object")
and (.teams | type == "object")
and (.memberships | type == "object")
and (.attempts | type == "object")
and (.quotas | type == "object")
and (.results | type == "object")
and (.scoring | type == "object")
' "$state" >/dev/null
for path in registry/*.json registry/*/*.json; do
[[ -f "$path" ]] || continue
[[ ! -L "$path" ]] || { printf 'symlink is not allowed: %s\n' "$path" >&2; exit 1; }
canonical="$RUNNER_TEMP/$(basename "$path").canonical"
jq -e -cS . "$path" > "$canonical"
cmp -s "$path" "$canonical" || { printf 'non-canonical JSON: %s\n' "$path" >&2; exit 1; }
done
jq -e --slurpfile s "$state" '.schema == "pythonhk.registry-users/v2" and .users == $s[0].users' registry/users/index.json >/dev/null
jq -e --slurpfile s "$state" '.schema == "pythonhk.registry-teams/v2" and .teams == $s[0].teams' registry/teams/index.json >/dev/null
jq -e --slurpfile s "$state" '.schema == "pythonhk.registry-memberships/v2" and .memberships == $s[0].memberships' registry/memberships/index.json >/dev/null
jq -e --slurpfile s "$state" '.schema == "pythonhk.registry-submissions/v2" and .attempts == $s[0].attempts' registry/submissions/index.json >/dev/null
"$EVENTCTL" doctor --event main/event/binding.json --registry registry/state.json
Loading
Loading