From a27cb0ca487407b637330261b6e1497d81dcdeed Mon Sep 17 00:00:00 2001 From: Alex Au Date: Sat, 8 Aug 2026 18:28:56 +0800 Subject: [PATCH] feat: migrate generic starter to eventctl v2 (#1) * Publish act-backed starter baseline * Use complete checkout cache for act * Fetch complete checkout tree for act * Build a deterministic act action cache * Seed act fixture refs without push transport * Provide a token to act checkout fixtures * Map the act host gateway on Linux * Advertise main in the act fixture remote * Seed act refs from shallow checkouts * Route request validators by changed path * feat: migrate starter to eventctl v2 * test: resolve pinned eventctl release in e2e * docs: use the release lock asset * chore: pin eventctl v0.2.0 * test: support hosted amd64 act fixtures --- .github/CODEOWNERS | 3 +- .github/actions/eventctl/action.yml | 48 + .github/workflows/admin-plan.yml | 79 +- .github/workflows/ci.yml | 55 +- .github/workflows/registration.yml | 45 +- .github/workflows/registry.yml | 49 +- .github/workflows/scoring.yml | 47 - .github/workflows/submission.yml | 64 +- .github/workflows/team-proof.yml | 70 +- .github/workflows/team-proposal.yml | 62 +- .gitignore | 6 +- CONTRIBUTING.md | 13 - README.md | 235 ++-- SECURITY.md | 67 +- docs/organizer/scoring.md | 28 - docs/organizer/workflow.md | 140 --- docs/participant/eventctl.md | 21 - docs/participant/registration.md | 25 - docs/participant/submissions.md | 28 - docs/participant/team-formation.md | 68 -- event/binding.json | 1 + event/event.example.yaml | 30 - event/terms.example.md | 8 - event/terms.md | 4 + mise.toml | 10 + .../schemas/v2/identity-registry.schema.json | 33 - protocol/schemas/v2/key-proof.schema.json | 13 - .../v2/registration-request.schema.json | 17 - .../schemas/v2/registry-state.schema.json | 100 -- .../schemas/v2/scoring-result.schema.json | 19 - .../schemas/v2/submission-request.schema.json | 25 - protocol/schemas/v2/team-proposal.schema.json | 38 - .../schemas/v2/team-signatures.schema.json | 28 - registry/identity-registry.json | 1 - registry/memberships/index.json | 1 - registry/scoring/.keep | 1 - registry/state.json | 2 +- registry/submissions/index.json | 1 - registry/teams/index.json | 1 - registry/users/index.json | 1 - scripts/actions/registration.sh | 52 - scripts/actions/submission.sh | 173 --- scripts/actions/team-proof.sh | 96 -- scripts/actions/team-proposal.sh | 146 --- scripts/admin/activate-team.sh | 253 ---- scripts/admin/apply-score.sh | 55 - scripts/admin/apply-submission.sh | 224 ---- scripts/admin/prepare-pr-metadata.sh | 57 - scripts/admin/rebuild-views.sh | 51 - scripts/admin/register-user.sh | 118 -- scripts/admin/set-lifecycle.sh | 62 - scripts/lib/common.sh | 168 --- scripts/participant/bundle-team-signatures.sh | 72 -- .../participant/prepare-submission-request.sh | 65 -- scripts/participant/prepare-team-proof.sh | 82 -- scripts/participant/prepare-team-proposal.sh | 91 -- scripts/scoring/validate-result.sh | 54 - settings/rulesets/main.json | 3 +- tests/e2e/bin/gh | 101 ++ tests/e2e/conftest.py | 10 + tests/e2e/test_workflows.py | 1031 +++++++++++++++++ tests/fixtures/proofs/101.json | 23 - tests/fixtures/proofs/102.json | 23 - tests/fixtures/proofs/103.json | 23 - tests/fixtures/registry-active.json | 16 - tests/fixtures/signatures.json | 10 - tests/fixtures/submission.json | 30 - tests/fixtures/team.json | 33 - tests/run-security.sh | 175 --- tools/eventctl | 20 - tools/eventctl.lock.json | 55 +- tools/install-eventctl.sh | 68 -- tools/verify-eventctl-lock.sh | 19 - 73 files changed, 1659 insertions(+), 3287 deletions(-) create mode 100644 .github/actions/eventctl/action.yml delete mode 100644 .github/workflows/scoring.yml delete mode 100644 CONTRIBUTING.md delete mode 100644 docs/organizer/scoring.md delete mode 100644 docs/organizer/workflow.md delete mode 100644 docs/participant/eventctl.md delete mode 100644 docs/participant/registration.md delete mode 100644 docs/participant/submissions.md delete mode 100644 docs/participant/team-formation.md create mode 100644 event/binding.json delete mode 100644 event/event.example.yaml delete mode 100644 event/terms.example.md create mode 100644 event/terms.md create mode 100644 mise.toml delete mode 100644 protocol/schemas/v2/identity-registry.schema.json delete mode 100644 protocol/schemas/v2/key-proof.schema.json delete mode 100644 protocol/schemas/v2/registration-request.schema.json delete mode 100644 protocol/schemas/v2/registry-state.schema.json delete mode 100644 protocol/schemas/v2/scoring-result.schema.json delete mode 100644 protocol/schemas/v2/submission-request.schema.json delete mode 100644 protocol/schemas/v2/team-proposal.schema.json delete mode 100644 protocol/schemas/v2/team-signatures.schema.json delete mode 100644 registry/identity-registry.json delete mode 100644 registry/memberships/index.json delete mode 100644 registry/scoring/.keep delete mode 100644 registry/submissions/index.json delete mode 100644 registry/teams/index.json delete mode 100644 registry/users/index.json delete mode 100644 scripts/actions/registration.sh delete mode 100644 scripts/actions/submission.sh delete mode 100644 scripts/actions/team-proof.sh delete mode 100644 scripts/actions/team-proposal.sh delete mode 100644 scripts/admin/activate-team.sh delete mode 100644 scripts/admin/apply-score.sh delete mode 100644 scripts/admin/apply-submission.sh delete mode 100644 scripts/admin/prepare-pr-metadata.sh delete mode 100644 scripts/admin/rebuild-views.sh delete mode 100644 scripts/admin/register-user.sh delete mode 100644 scripts/admin/set-lifecycle.sh delete mode 100644 scripts/lib/common.sh delete mode 100644 scripts/participant/bundle-team-signatures.sh delete mode 100644 scripts/participant/prepare-submission-request.sh delete mode 100644 scripts/participant/prepare-team-proof.sh delete mode 100644 scripts/participant/prepare-team-proposal.sh delete mode 100644 scripts/scoring/validate-result.sh create mode 100755 tests/e2e/bin/gh create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_workflows.py delete mode 100644 tests/fixtures/proofs/101.json delete mode 100644 tests/fixtures/proofs/102.json delete mode 100644 tests/fixtures/proofs/103.json delete mode 100644 tests/fixtures/registry-active.json delete mode 100644 tests/fixtures/signatures.json delete mode 100644 tests/fixtures/submission.json delete mode 100644 tests/fixtures/team.json delete mode 100644 tests/run-security.sh delete mode 100644 tools/eventctl delete mode 100644 tools/install-eventctl.sh delete mode 100644 tools/verify-eventctl-lock.sh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e30c9a3..9bce7dd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/actions/eventctl/action.yml b/.github/actions/eventctl/action.yml new file mode 100644 index 0000000..c6bed8e --- /dev/null +++ b/.github/actions/eventctl/action.yml @@ -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" diff --git a/.github/workflows/admin-plan.yml b/.github/workflows/admin-plan.yml index 01d2889..9b0fd59 100644 --- a/.github/workflows/admin-plan.yml +++ b/.github/workflows/admin-plan.yml @@ -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 @@ -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 @@ -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.' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 860067a..a134296 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Event CI +name: Event E2E on: push: @@ -6,36 +6,47 @@ on: 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 diff --git a/.github/workflows/registration.yml b/.github/workflows/registration.yml index 91ea48f..42bf962 100644 --- a/.github/workflows/registration.yml +++ b/.github/workflows/registration.yml @@ -3,6 +3,8 @@ name: Participant registration validation on: pull_request_target: types: [opened, synchronize, reopened] + paths: + - 'requests/users/*.json' permissions: contents: read @@ -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' diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml index 88a730c..2703a98 100644 --- a/.github/workflows/registry.yml +++ b/.github/workflows/registry.yml @@ -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 diff --git a/.github/workflows/scoring.yml b/.github/workflows/scoring.yml deleted file mode 100644 index 7b4977e..0000000 --- a/.github/workflows/scoring.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Isolated scoring hook - -on: - workflow_dispatch: - inputs: - attempt_id: - description: Reserved attempt to score - required: true - type: string - result_path: - description: Organizer-provided result envelope path - required: true - type: string - payload_path: - description: Organizer-provided public result payload path - required: true - type: string - -permissions: - contents: read - -jobs: - validate: - name: scoring / isolated-result-check - if: github.ref == 'refs/heads/registry' - runs-on: ubuntu-24.04 - environment: scoring - steps: - - name: Check out trusted registry - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: registry - persist-credentials: false - - name: Validate organizer result without participant execution - shell: bash - env: - ATTEMPT_ID: ${{ inputs.attempt_id }} - RESULT_PATH: ${{ inputs.result_path }} - PAYLOAD_PATH: ${{ inputs.payload_path }} - run: | - set -Eeuo pipefail - [[ "$ATTEMPT_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]] - [[ "$RESULT_PATH" =~ ^registry/scoring/[A-Za-z0-9._-]+\.result\.json$ ]] || { printf '%s\n' 'result path is invalid'; exit 1; } - [[ "$PAYLOAD_PATH" =~ ^registry/scoring/[A-Za-z0-9._-]+\.payload\.json$ ]] || { printf '%s\n' 'payload path is invalid'; exit 1; } - test -f "$RESULT_PATH" - test -f "$PAYLOAD_PATH" - bash scripts/scoring/validate-result.sh --state registry/state.json --result "$RESULT_PATH" --payload "$PAYLOAD_PATH" diff --git a/.github/workflows/submission.yml b/.github/workflows/submission.yml index b199cc3..e8fbaf1 100644 --- a/.github/workflows/submission.yml +++ b/.github/workflows/submission.yml @@ -3,6 +3,9 @@ name: Submission validation on: pull_request_target: types: [opened, synchronize, reopened] + paths: + - 'requests/submissions/*/request.json' + - 'requests/submissions/*/bundle' permissions: contents: read @@ -20,41 +23,50 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - - name: Validate untrusted submission + + - id: eventctl + name: Resolve pinned eventctl + uses: ./.github/actions/eventctl + with: + test-bin: ${{ env.EVENTCTL_E2E_BIN }} + + - name: Verify the signed submission against protected state env: + EVENTCTL: ${{ steps.eventctl.outputs.path }} GH_TOKEN: ${{ github.token }} EVENT_REPOSITORY: ${{ github.repository }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} - EVENT_PR_ID: ${{ github.event.pull_request.id }} 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" == 2 ]] || event_die "submission PR must change exactly request.json and bundle.eventctl" - request_path=$(grep -E '^requests/submissions/[^/]+/request\.json$' "$paths" || true) - bundle_path=$(grep -E '^requests/submissions/[^/]+/bundle\.eventctl$' "$paths" || true) - [[ -n "$request_path" && -n "$bundle_path" ]] || event_die 'submission PR paths are invalid' + 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 ' ')" = 2 + request_path=$(grep -E '^requests/submissions/[0-9a-f-]+/request\.json$' "$paths") + bundle_path=$(grep -E '^requests/submissions/[0-9a-f-]+/bundle$' "$paths") request_dir=${request_path%/request.json} - [[ "$bundle_path" == "$request_dir/bundle.eventctl" ]] || event_die 'submission files must share one attempt directory' - event_fetch_pr_blob "$EVENT_REPOSITORY" "$EVENT_HEAD_SHA" "$request_path" "$RUNNER_TEMP/request.json" - event_fetch_pr_blob "$EVENT_REPOSITORY" "$EVENT_HEAD_SHA" "$bundle_path" "$RUNNER_TEMP/bundle.eventctl" - event_fetch_pr_blob "$EVENT_REPOSITORY" registry registry/state.json "$RUNNER_TEMP/registry.json" - path_attempt=${request_dir#requests/submissions/} - [[ "$(jq -er '.attempt_id' "$RUNNER_TEMP/request.json")" == "$path_attempt" ]] || event_die 'submission path attempt ID does not match the signed request' - bash scripts/actions/submission.sh \ - --request "$RUNNER_TEMP/request.json" \ + test "$bundle_path" = "$request_dir/bundle" + gh api -H 'Accept: application/vnd.github.raw+json' \ + "repos/${EVENT_REPOSITORY}/contents/${request_path}?ref=${EVENT_HEAD_SHA}" \ + > "$RUNNER_TEMP/request.json" + gh api -H 'Accept: application/vnd.github.raw+json' \ + "repos/${EVENT_REPOSITORY}/contents/${bundle_path}?ref=${EVENT_HEAD_SHA}" \ + > "$RUNNER_TEMP/bundle" + gh api -H 'Accept: application/vnd.github.raw+json' \ + "repos/${EVENT_REPOSITORY}/contents/registry/state.json?ref=registry" \ + > "$RUNNER_TEMP/registry.json" + "$EVENTCTL" submission verify \ + --event event/binding.json \ --registry "$RUNNER_TEMP/registry.json" \ - --bundle "$RUNNER_TEMP/bundle.eventctl" \ - --actual-author "$EVENT_AUTHOR_ID" \ - --actual-pr "$EVENT_PR_NUMBER" \ - --actual-pr-id "$EVENT_PR_ID" \ - --actual-fork-repository-id "$(jq -er '.pull_request.head.repo.id' "$GITHUB_EVENT_PATH")" \ - --actual-head-owner "$(jq -er '.pull_request.head.repo.owner.login' "$GITHUB_EVENT_PATH")" \ - --actual-head-branch "$(jq -er '.pull_request.head.ref' "$GITHUB_EVENT_PATH")" \ - --actual-head-sha "$EVENT_HEAD_SHA" + --request "$RUNNER_TEMP/request.json" \ + --bundle "$RUNNER_TEMP/bundle" \ + --expect-actor-id "$EVENT_AUTHOR_ID" \ + --source-time "$EVENT_SOURCE_TIME" \ + --output "$RUNNER_TEMP/verified-submission.json" + printf '%s\n' 'submission request is signed, actor-bound, active-team-bound, and available' diff --git a/.github/workflows/team-proof.yml b/.github/workflows/team-proof.yml index d1513be..53a1959 100644 --- a/.github/workflows/team-proof.yml +++ b/.github/workflows/team-proof.yml @@ -1,8 +1,10 @@ -name: Team proof validation +name: Team consent validation on: pull_request_target: types: [opened, synchronize, reopened] + paths: + - 'requests/teams/*/proofs/*.json' permissions: contents: read @@ -10,7 +12,7 @@ permissions: jobs: validate: - name: team / member-proof + name: team / member-consent if: github.event.pull_request.base.ref == 'main' runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -20,8 +22,16 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - - name: Validate actor-bound proof + + - id: eventctl + name: Resolve pinned eventctl + uses: ./.github/actions/eventctl + with: + test-bin: ${{ env.EVENTCTL_E2E_BIN }} + + - name: Check the actor-bound consent for the exact proposal env: + EVENTCTL: ${{ steps.eventctl.outputs.path }} GH_TOKEN: ${{ github.token }} EVENT_REPOSITORY: ${{ github.repository }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} @@ -31,23 +41,39 @@ jobs: 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 "proof PR must change exactly one file, got $count" - proof_path=$(grep -E '^requests/teams/[^/]+/proofs/[1-9][0-9]{0,19}\.json$' "$paths" || true) - [[ -n "$proof_path" ]] || event_die 'proof PR path is invalid' - path_actor=${proof_path##*/} - path_actor=${path_actor%.json} - [[ "$path_actor" == "$EVENT_AUTHOR_ID" ]] || event_die 'proof filename must equal authenticated actor ID' - proof_dir=${proof_path%/*} - registration_dir=${proof_dir%/proofs} - proposal_path="$registration_dir/team.json" - event_fetch_pr_blob "$EVENT_REPOSITORY" "$EVENT_BASE_SHA" "$proposal_path" "$RUNNER_TEMP/team.json" - event_fetch_pr_blob "$EVENT_REPOSITORY" "$EVENT_HEAD_SHA" "$proof_path" "$RUNNER_TEMP/proof.json" - bash scripts/actions/team-proof.sh \ - --proposal "$RUNNER_TEMP/team.json" \ - --proof "$RUNNER_TEMP/proof.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 + proof_path=$(cat "$paths") + [[ "$proof_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})/proofs/([1-9][0-9]{0,19})\.json$ ]] + team_id=${BASH_REMATCH[1]} + actor_id=${BASH_REMATCH[2]} + test "$actor_id" = "$EVENT_AUTHOR_ID" + proposal_path="requests/teams/${team_id}/proposal.json" + gh api -H 'Accept: application/vnd.github.raw+json' \ + "repos/${EVENT_REPOSITORY}/contents/${proposal_path}?ref=${EVENT_BASE_SHA}" \ + > "$RUNNER_TEMP/proposal.json" + gh api -H 'Accept: application/vnd.github.raw+json' \ + "repos/${EVENT_REPOSITORY}/contents/${proof_path}?ref=${EVENT_HEAD_SHA}" \ + > "$RUNNER_TEMP/consent.json" + "$EVENTCTL" doctor --event event/binding.json > "$RUNNER_TEMP/doctor.json" + proposal_sha256=$(sha256sum "$RUNNER_TEMP/proposal.json" | awk '{print $1}') + jq -e \ + --arg actor "$actor_id" \ + --arg team "$team_id" \ + --arg digest "$proposal_sha256" \ + --argjson event "$(jq -c '.result.event' "$RUNNER_TEMP/doctor.json")" \ + ' + (keys_unsorted | sort) == ["actor_id", "event", "expires_at", "issued_at", "key_epoch", "kind", "proposal_sha256", "signature", "signing_kid", "team_id", "v"] + and .v == 2 + and .kind == "team-consent" + and .event == $event + and .team_id == $team + and .actor_id == $actor + and .proposal_sha256 == $digest + and ((.signature | keys_unsorted | sort) == ["alg", "kid", "value"]) + ' "$RUNNER_TEMP/consent.json" >/dev/null + printf '%s\n' 'team consent is actor-bound; final signature and registry verification is organizer-only' diff --git a/.github/workflows/team-proposal.yml b/.github/workflows/team-proposal.yml index 51d5df0..1644ae2 100644 --- a/.github/workflows/team-proposal.yml +++ b/.github/workflows/team-proposal.yml @@ -3,6 +3,8 @@ name: Team proposal validation on: pull_request_target: types: [opened, synchronize, reopened] + paths: + - 'requests/teams/*/proposal.json' permissions: contents: read @@ -20,32 +22,52 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - - name: Validate exact request files + + - id: eventctl + name: Resolve pinned eventctl + uses: ./.github/actions/eventctl + with: + test-bin: ${{ env.EVENTCTL_E2E_BIN }} + + - name: Check the pending proposal shape and proposer 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 }} 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" == 2 ]] || event_die "team proposal must change exactly 2 files, got $count" - proposal_path=$(grep -E '^requests/teams/[^/]+/team\.json$' "$paths" || true) - signatures_path=$(grep -E '^requests/teams/[^/]+/signatures\.json$' "$paths" || true) - [[ -n "$proposal_path" && -n "$signatures_path" ]] || event_die 'proposal PR must contain team.json and signatures.json only' - proposal_dir=${proposal_path%/team.json} - [[ "$signatures_path" == "$proposal_dir/signatures.json" ]] || event_die 'team proposal files must share one registration directory' - event_fetch_pr_blob "$EVENT_REPOSITORY" "$EVENT_HEAD_SHA" "$proposal_path" "$RUNNER_TEMP/team.json" - event_fetch_pr_blob "$EVENT_REPOSITORY" "$EVENT_HEAD_SHA" "$signatures_path" "$RUNNER_TEMP/signatures.json" - path_registration=${proposal_path#requests/teams/} - path_registration=${path_registration%/team.json} - [[ "$(jq -er '.registration_id' "$RUNNER_TEMP/team.json")" == "$path_registration" ]] || event_die 'proposal path registration ID does not match the signed request' - status_file="$RUNNER_TEMP/status.json" - status=$(bash scripts/actions/team-proposal.sh --proposal "$RUNNER_TEMP/team.json" --signatures "$RUNNER_TEMP/signatures.json" --status-out "$status_file") - jq -e . "$status_file" >/dev/null - echo "::notice::team proposal status: $status" + 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 + proposal_path=$(cat "$paths") + [[ "$proposal_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\.json$ ]] + team_id=${BASH_REMATCH[1]} + gh api -H 'Accept: application/vnd.github.raw+json' \ + "repos/${EVENT_REPOSITORY}/contents/${proposal_path}?ref=${EVENT_HEAD_SHA}" \ + > "$RUNNER_TEMP/proposal.json" + "$EVENTCTL" doctor --event event/binding.json > "$RUNNER_TEMP/doctor.json" + jq -e \ + --arg actor "$EVENT_AUTHOR_ID" \ + --arg team "$team_id" \ + --argjson event "$(jq -c '.result.event' "$RUNNER_TEMP/doctor.json")" \ + ' + (keys_unsorted | sort) == ["event", "expires_at", "issued_at", "kind", "members", "proposer", "signature", "team_id", "v"] + and .v == 2 + and .kind == "team-proposal" + and .event == $event + and .team_id == $team + and .proposer.actor_id == $actor + and (.members | type == "array" and length > 0) + and (.members | all(.[]; (keys_unsorted | sort) == ["actor_id", "key_epoch", "recipient_kid", "signing_kid"])) + and ([.members[].actor_id] | length == (unique | length) and . == sort_by(tonumber)) + and (. as $proposal | any($proposal.members[]; . == $proposal.proposer)) + and ((.signature | keys_unsorted | sort) == ["alg", "kid", "value"]) + ' "$RUNNER_TEMP/proposal.json" >/dev/null + printf '%s\n' 'team proposal is pending member consents; organizer verification remains required' diff --git a/.gitignore b/.gitignore index 73d0ca8..f89b8f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,7 @@ .DS_Store .tmp/ .cache/ -tools/.cache/ .eventctl/ *.key.age -*.pem -*.private.json -registry/.working/ +.pytest_cache/ +__pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 85e7187..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,13 +0,0 @@ -# Contributing - -Changes to workflows, schemas, request parsing, registry reducers, or branch -controls require a security review. Keep participant-controlled data separate -from trusted workflow code. Add a hostile-input test for every new parser rule -and run: - -```bash -bash tests/run-security.sh -``` - -Do not add a custom GitHub App, a long-lived token, or a dependency that executes -participant code without updating the threat model and protocol version. diff --git a/README.md b/README.md index df863d3..4041832 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,168 @@ -# GitHub-native event starter — organizer-trusted PR model +# GitHub-native event starter -This is the repository-only successor to the App-centric v1 starter. It is a -public, reusable GitHub template for bounded events where the organizer is the -trusted state-transition authority. +This is the small, clone-based starter for a PythonHK event. It uses the +released `eventctl/v2` CLI and ordinary protected GitHub branches; it has no +GitHub App, PEM, application token, database, or server. -The default operating model has two branch roles: +```text +main event policy, trusted workflows, and merged participant requests +registry organizer-reviewed identities, active teams, and accepted attempts +``` + +A participant request is public and never changes authoritative state by +itself. The organizer is trusted to review a normal `admin/*` pull request to +`registry` after cryptographic verification succeeds. + +## Create an event + +1. Create a new repository from this template. Do not make the template's + repository ID part of the event identity. +2. Create and protect a `registry` branch from the same reviewed commit. + Require an organizer review, linear history, no force pushes or deletion, + and the `registry / canonical-state` check. Protect `main` the same way and + require `event / act-e2e`. +3. Replace `event/terms.md` and update `event/binding.json` together: choose + the event ID, numeric GitHub repository ID, validity window, request TTLs, + and team/attempt limits. Set `terms_sha256` to the SHA-256 of the exact + terms file. +4. Run `eventctl doctor --event event/binding.json` and copy its complete + `result.event` object into `registry/state.json.event`. Leave the registry + in `draft`, disabled, with `bootstrap_required` until the event is ready. +5. After the reviewed `eventctl/v2` release exists, copy its + `eventctl.lock.json` release asset to `tools/eventctl.lock.json`. Trusted + workflows and the E2E suite download and verify only the platform-specific + asset they need. +6. Review the bootstrap PR, create the protected branches, then set the + registry phase to `registration_open` and enable it through an organizer PR. + +`registry/state.json` is the only authoritative state document. Its event +reference, phase, active identities, teams, and attempts are checked by +`eventctl doctor`; it intentionally has no derived identity or membership +views to keep in sync. + +## Participant flow + +Each participant runs this once, outside their event checkout: ```text -main -├── trusted workflows, schemas, and event documentation -├── merged participant request history -└── no participant-controlled workflow changes - -registry -└── protected normalized public state - users, teams, memberships, attempts, quotas, results, and scoring policy +eventctl key-gen --out ~/.eventctl/identity --passphrase-file passphrase.txt ``` -Participants push to a branch in their own fork and open pull requests to the -upstream event repository's `main` branch. Team proposals, member proofs, and -submissions are all request files; they do not write authoritative state. -An organizer reviews accepted requests and merges one atomic activation or -state-update PR into `registry`. +The command makes separate Ed25519 signing and age hybrid recipient keys. One +passphrase protects both private files; the keys are not interchangeable. -There is no custom event-specific GitHub App in the normal runtime path. GitHub -Actions performs read-only validation with `pull_request_target`, using only -trusted base-branch code and exact API-fetched blobs. It never checks out or -executes fork code and never receives organizer secrets. +### Register -The pinned `eventctl` v1 trust documents still contain a logical writer/state -label for protocol compatibility. In this starter those fields are -organizer-control labels only; the outer repository's authoritative state is -the reviewed `registry` branch, and no GitHub App installation, App token, or -App private key is required. +```text +eventctl identity register \ + --event event/binding.json \ + --actor-id \ + --sig-private-key ~/.eventctl/identity/signing.private.age \ + --recipient-public-key ~/.eventctl/identity/recipient.public.json \ + --passphrase-file passphrase.txt \ + --output requests/users/.json +``` -## Team formation +From a fork, open a pull request changing exactly that file. The trusted +workflow verifies its signature, event reference, and that the filename/claim +equals the authenticated GitHub actor. The organizer then runs the same +verification using the immutable pull-request creation time and writes the +verified identity record to a reviewed `registry` PR. -Participants first register an Ed25519 identity through an exact -`requests/users/.json` PR. See -[docs/participant/registration.md](docs/participant/registration.md). -Install the shared, checksum-pinned Go CLI once with -[docs/participant/eventctl.md](docs/participant/eventctl.md); the event -repository contains no per-event application runtime. +### Form a team -Team formation is deliberately staged so a public, copyable proposal cannot -reserve another participant or consume quota: +After the organizer has activated the identities and set `formation_open`, the +proposer reads the public registry branch and creates a proposal: ```text -team proposal PR - -> PENDING_KEY_PROOFS - -> one key-proof/binding PR from each listed member - -> READY_TO_ACTIVATE - -> organizer activation PR to registry - -> ACTIVE, REJECTED, or EXPIRED +eventctl team propose \ + --event event/binding.json --registry registry/state.json \ + --actor-id --member --member \ + --sig-private-key ~/.eventctl/identity/signing.private.age \ + --passphrase-file passphrase.txt \ + --output requests/teams//proposal.json ``` -The proposal directory contains a canonical `team.json` and a deterministic -member-signature envelope. Each member proof binds the event, repository, -registration ID, numeric GitHub actor ID, and public key. The workflow compares -the claimed actor with the authenticated PR author; copying a proof from another -participant therefore fails. +The proposer opens a PR with exactly `proposal.json`; it is only a pending +request. Every listed member—including the proposer—then submits one separate +PR from their own fork containing: -Pending proposals do not reserve GitHub IDs, public keys, team names, -membership, quota, or leaderboard identity. The first fully verified proposal -that passes the organizer's current-state check and is merged wins. +```text +requests/teams//proofs/.json +``` + +created by `eventctl team consent`. The proof workflow binds its filename and +document actor to the GitHub PR author. It does not activate a team. Pending +proposals reserve neither a member nor a quota. + +The organizer's no-write verification plan receives immutable GitHub creation +times, for example: + +```json +{ + "proposal": "2026-08-10T10:00:00Z", + "consents": { + "123": "2026-08-10T10:05:00Z", + "456": "2026-08-10T10:07:00Z" + } +} +``` -The current pinned `eventctl` release uses Ed25519 identities and explicit -multi-signature documents. The envelope is intentionally named and documented -as an explicit multi-signature container, not as a mathematical BLS aggregate. -True BLS aggregation can be introduced as a separately reviewed protocol -version without changing the repository workflow. +It runs `eventctl team verify` against the current protected registry. Only a +reviewed follow-up state PR adds the returned team record and increments the +registry revision. A competing or stale plan must be regenerated against the +new registry head. -## Submission model +### Submit -Submissions are also untrusted PR proposals: +When the organizer moves the registry to `submissions_open`, a member signs +the exact opaque bundle they want to submit: ```text -fork branch - -> encrypted bundle + signed request - -> read-only intake check - -> organizer review/merge - -> one registry transition for (team_id, attempt_id) +eventctl submission prepare \ + --event event/binding.json --input bundle \ + --team-id --attempt-id --actor-id \ + --sig-private-key ~/.eventctl/identity/signing.private.age \ + --passphrase-file passphrase.txt \ + --output requests/submissions//request.json ``` -The same content may be submitted again with a fresh attempt ID by the same -authorized participant. Replaying an existing signed attempt is idempotent; -presenting it from another actor, fork, PR, branch, or head SHA is rejected. +The matching `bundle` lives next to the request. The intake workflow verifies +the signed payload, authenticated PR actor, active membership, unused attempt +ID, and both quotas against `registry`. An organizer records the accepted +attempt in a reviewed registry PR. The replay key is `(team_id, attempt_id)`: +the same content may be submitted again using a new attempt ID, but an attempt +ID may not be transferred or changed. -Scoring is a separate organizer-controlled hook. It reads only a reserved -attempt and a public result payload, never executes participant code, and can -write a result only through an admin PR that marks the attempt completed. The -event can be disabled or moved to `frozen`/`closed` through the lifecycle -controls without changing request history. +## Encrypted result artifacts -## One-time organizer setup +The scorer can encrypt logs to every active team recipient and upload the +ciphertext as a GitHub Actions artifact: -The organizer creates the event repository from this template, creates the -protected `registry` branch, and publishes the signed genesis/configuration. -`event/event.example.yaml` is only the repository's lifecycle metadata example; -the signed v1 `eventctl` configuration and protected trust files are organizer -inputs and must be generated and verified separately before activation. -An optional organizer PEM or signing key is supplied only during this -bootstrap/administration ceremony and is never exposed to fork-triggered jobs. -If activation PRs are merged manually, the bootstrap credential can be retired -after genesis. +```text +eventctl sigcrypt --context stream-binding.json \ + --sig-private-key organizer-signing.private.age \ + --enc-public-key member-one/recipient.public.json \ + --enc-public-key member-two/recipient.public.json \ + --input judge.log --output judge.log.eventctl +``` -The repository contains no participant private keys, organizer private keys, -GitHub tokens, or confidential plaintext. Every public branch and fork is -assumed observable. +Each recipient runs `eventctl decverify` with the organizer signing public key +and their own recipient private key. The event does not need a shared team +private key or an internal key server. -Normal organizer activation is cryptographic: `scripts/admin/activate-team.sh` -requires the pinned `eventctl` binary, protected trust files, and a source-time -map for the proof PRs. Its explicit `--plan-only` mode exists only for local -hostile-input tests and says that cryptographic verification was not run. +## Testing -## Layout +There is one test entrypoint: ```text -.github/workflows/ trusted read-only checks and organizer dispatches -event/ event configuration and terms -protocol/schemas/v2/ strict request and registry schemas -requests/ merged public request history on main -registry/ normalized authoritative state on registry -scripts/participant/ request preparation helpers -scripts/actions/ untrusted-data validators -scripts/admin/ organizer-only plan/apply/view-rebuild helpers -tests/ hostile-input and state-model tests +mise run test ``` -Read [docs/organizer/workflow.md](docs/organizer/workflow.md) before creating a -derived event. Read [docs/participant/team-formation.md](docs/participant/team-formation.md) -before preparing a team proposal. +Pytest coordinates `act`, Docker, a deterministic GitHub API fixture, and the +real `eventctl` executable against the checked-in workflow YAML. It never runs +participant fork code. Once the release is pinned, a clean clone downloads and +verifies the exact host and `act` binaries automatically. Before the first pin, +local development supplies `EVENTCTL_E2E_NATIVE_BIN` for the host executable +and `EVENTCTL_E2E_BIN` for the matching Linux executable that `act` runs. A +published event never uses either test-only override. diff --git a/SECURITY.md b/SECURITY.md index 9ffdb10..9b7e5b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,51 +1,20 @@ # Security policy -## Scope - -This starter assumes a transparent, mutually distrustful event: participants -can read public request history and registry state, but no participant is -implicitly trusted to change authoritative state. - -The organizer, GitHub's authenticated pull-request metadata, the protected -base-branch workflows, and the protected `registry` branch are trusted -components. The protocol does not claim security against a malicious organizer, -compromised GitHub account, compromised participant key, modified trusted -workflow, or GitHub-wide compromise. - -## Required properties - -- A participant key proof is accepted only when its numeric actor ID equals the - authenticated PR author ID. -- A team becomes active only after every listed member has authenticated a proof - bound to the exact team proposal. -- Pending proposals reserve no state and cannot lock other participants. -- A submission is attributed to the team resolved from authoritative membership, - never solely from a self-declared `team_id`. -- A submission wrapper and its embedded eventctl envelope must agree with the - immutable upstream PR number/ID, fork repository, head branch/SHA, and - bundle digest. -- `(team_id, attempt_id)` is the idempotency key. Same content with a new attempt - ID is an intentional new attempt; replaying the same attempt is harmless. -- `registry` is the only authoritative state branch. It is protected against - deletion, force-push, and non-linear history. -- Fork contents, filenames, commit messages, PR text, usernames, and workflow - artifacts are hostile data. No privileged workflow executes them. -- Structural intake checks are eligibility signals only. The organizer's - normal activation path must re-verify the embedded eventctl registration, - proposal, and consent signatures against protected trust files and immutable - PR source times; a local `--plan-only` result is not an activation. - -## Pull-request trust boundary - -Participant workflows may use `pull_request_target` only to run trusted -base-branch code. They fetch and bound exact JSON blobs through the GitHub API; -they do not checkout the PR head, source shell files, install dependencies from -the PR, or expose secrets. An organizer-only workflow may prepare an activation -plan, but the organizer must review and merge the resulting `registry` PR. - -## Reporting - -Do not disclose private keys, organizer credentials, or confidential submissions -in public issues or pull requests. Contact the event organizer through the -published security route with the event ID, affected request, and a minimal -synthetic reproduction. +This template assumes every fork and every file under `requests/` is public, +untrusted input. Report a vulnerability privately through the repository's +GitHub Security tab with a synthetic reproduction and affected commit. + +- Keep `main` and `registry` protected; only organizer-reviewed registry PRs + make an identity, team, or attempt active. +- Use `pull_request_target` only with trusted base-branch code. Do not check + out, execute, source, or install code from a participant fork. +- Treat GitHub's authenticated numeric actor ID and pull-request creation time + as trusted delivery facts. Do not substitute a participant-controlled commit + timestamp. +- Pin the released `eventctl` binary by archive and binary SHA-256. Never use + a mutable release URL or an unreviewed local binary for organizer decisions. +- Do not commit private keys, passphrases, decrypted logs, plaintext judge + output, or GitHub tokens. +- There is no normal GitHub App, PEM, secret, or remote service in this model. + An organizer's branch protection and reviewed registry transition are the + authority boundary. diff --git a/docs/organizer/scoring.md b/docs/organizer/scoring.md deleted file mode 100644 index 1a102da..0000000 --- a/docs/organizer/scoring.md +++ /dev/null @@ -1,28 +0,0 @@ -# Scoring hook - -Scoring is intentionally outside participant intake. An organizer-controlled -runner may inspect a reserved encrypted bundle in the approved isolated judge, -then publish only a bounded public payload and a `pythonhk.scoring-result/v2` -envelope under `registry/scoring/`. - -The result must bind: - -- the exact event, team, and attempt; -- the stored submission request digest; -- the payload's SHA-256 digest; and -- the scorer identity and version. - -Run the read-only hook with the protected `registry` checkout: - -```text -bash scripts/scoring/validate-result.sh \ - --state registry/state.json \ - --result registry/scoring/.result.json \ - --payload registry/scoring/.payload.json -``` - -After review, `scripts/admin/apply-score.sh` updates `registry/state.json` in -one admin PR, marks the attempt `completed`, and increments the registry -revision. It refuses duplicate results, unreserved attempts, mismatched team -IDs, and payload changes. Scoring remains disabled unless the state explicitly -sets `scoring.enabled` and `isolation: organizer-controlled`. diff --git a/docs/organizer/workflow.md b/docs/organizer/workflow.md deleted file mode 100644 index 2033fcf..0000000 --- a/docs/organizer/workflow.md +++ /dev/null @@ -1,140 +0,0 @@ -# Organizer workflow - -## Branches - -The event has one public request branch and one authoritative state branch: - -```text -main - trusted workflow code, schemas, event documentation, and merged request files - -registry - normalized public state; organizer-only protected branch -``` - -Do not create separate authoritative `team-registration` and `submission` -branches. Both operations resolve the same users, memberships, lifecycle, and -quota state. A single `registry` branch is the consistency boundary. - -Temporary organizer branches use `admin/` and become authoritative only after a -reviewed merge into `registry`. - -The shared `eventctl` v1 configuration contains a compatibility logical -writer/state label because the CLI release is reused by older event profiles. -Keep that label bound in the protected trust files, but do not create an -event-specific App for this starter: the actual writer is the -organizer-reviewed `registry` commit. - -## Bootstrap - -1. Create a new repository from the template; do not fork the starter as the - event identity. -2. Protect `main` and `registry` with CODEOWNERS, pull-request review, no force - pushes, no deletion, and linear history. -3. Create `registry` from the reviewed genesis commit. The initial files are: - - ```text - registry/state.json - registry/identity-registry.json - registry/users/index.json - registry/teams/index.json - registry/memberships/index.json - registry/submissions/index.json - ``` - -4. Render the event metadata and exact terms on `main` in one organizer PR. - `event/event.example.yaml` is the repository lifecycle template, not a - substitute for the signed v1 configuration consumed by `eventctl`. Publish - the exact eventctl config, protected genesis/authority, state metadata, and - identity registry paths used by the organizer runbook; keep private keys out - of Git. Keep the event disabled until this PR is reviewed and merged. -5. Optionally use one organizer PEM/signing key to publish genesis or prepare - the first administrative PR. Never put it in Git and never expose it to - `pull_request_target` jobs. - -## Participant PRs - -Participants push to their own fork branch and open PRs to upstream `main`. -The trusted workflows only inspect bounded request paths: - -```text -requests/teams//team.json -requests/teams//signatures.json -requests/teams//proofs/.json -requests/users/.json -requests/submissions//request.json -requests/submissions//bundle.eventctl -``` - -The PR itself is an untrusted proposal. A passing check means only that the -proposal is eligible for organizer review; it does not activate a team, reserve -quota, or record a score. - -## Team activation - -The organizer creates `admin/activate-team-` from the current -`registry` head. The activation plan must: - -- reread the exact merged proposal and every proof; -- verify all signatures and actor bindings; -- run the pinned `eventctl team verify` and `eventctl identity verify` commands - with protected configuration, authority, registry, and immutable source - times (a structural `--plan-only` output is never an activation); -- recheck lifecycle, expiry, team-size, key, and membership constraints; -- refuse a participant who is already active in another team; -- write users, team, and membership records in one commit; and -- retain source PR numbers, immutable head SHAs, and request digests. - -After every state transition, run `scripts/admin/rebuild-views.sh` in the same -admin PR so the four public index files remain deterministic materialized views -of `registry/state.json`; they are never an independent authority. - -The organizer reviews and merges the activation PR into `registry`. If two -activations race, only the first valid merge against the current registry head -wins; the other plan must be regenerated. - -## Submission admission - -The submission workflow verifies the signed request against the merged -`registry` snapshot and the authenticated PR metadata. It must bind: - -```text -event, repository, actor, active team, attempt ID, -upstream PR number/database ID, fork repository, head branch/SHA, -configuration digest, bundle digest, and validity window -``` - -The organizer creates an admin state PR for a valid reservation. The replay key -is `(team_id, attempt_id)`, not the content digest. The same content with a new -attempt ID is allowed for the same authorized actor; copying an old signed -request into another actor's PR is rejected. - -`scripts/admin/apply-submission.sh` normally requires the pinned `eventctl` -binary, protected trust files, immutable PR metadata, and the bundle. It runs -`eventctl submission verify-request` before writing state. Exact replays return -the already recorded attempt without incrementing the registry revision; -conflicting reuses of an attempt ID fail closed. - -The `eventctl` metadata file is a small canonical projection of the immutable -GitHub pull-request event, not the raw GitHub event payload. Prepare it before -the admin plan and retain it with the review evidence: - -```text -bash scripts/admin/prepare-pr-metadata.sh \ - --github-event /secure/evidence/github-event.json \ - --out /secure/evidence/pr-metadata.json -``` - -## Lifecycle - -Lifecycle changes are organizer PRs to `registry`: - -```text -draft -> registration_open -> formation_open -> submissions_open - -> frozen -> closed -> archived -``` - -All mutating checks reread `registry/state.json` at the current branch head. -Disabling an event stops new proposal, proof, submission, and scoring plans. -Scoring follows the separate [scoring hook](scoring.md) and never executes -participant-controlled source in a GitHub Actions job. diff --git a/docs/participant/eventctl.md b/docs/participant/eventctl.md deleted file mode 100644 index c31ed12..0000000 --- a/docs/participant/eventctl.md +++ /dev/null @@ -1,21 +0,0 @@ -# Installing the participant CLI - -The event publishes one pinned Go CLI release for all events. Participants do -not build event-specific code and do not need Python or Node.js. The repository -lock file records the release asset and binary SHA-256 values. - -From a checkout of the event repository: - -```text -./tools/install-eventctl.sh --version 1.0.0 -./tools/eventctl --help -``` - -The installer selects the host platform, downloads only the locked GitHub -release asset, verifies both the archive and extracted binary, and installs it -under `tools/.cache/`. Keep the private identity key outside Git; only signed -public request documents and encrypted bundles belong in fork branches. - -Organizers update `tools/eventctl.lock.json` only as a separately reviewed -protocol release. A participant must never substitute an unpinned local binary -for the release used by organizer verification. diff --git a/docs/participant/registration.md b/docs/participant/registration.md deleted file mode 100644 index 9d00b99..0000000 --- a/docs/participant/registration.md +++ /dev/null @@ -1,25 +0,0 @@ -# Participant registration - -Create an Ed25519 identity with the pinned `eventctl` release, using the -organizer's published event configuration and trust files: - -```text -eventctl identity register ... --actor-id --out registration.json -``` - -Push exactly this file from your own fork branch and open a PR to upstream -`main`: - -```text -requests/users/.json -``` - -The read-only check compares the request actor with the authenticated PR -author. A passing check is only an eligibility signal. The organizer re-runs -`eventctl identity verify` at the PR's immutable source time and merges one -admin state PR into `registry`, updating both `registry/state.json` and the -eventctl-compatible `registry/identity-registry.json`. - -Do not commit the private key. A key ID is bound to one actor and one active -epoch; changing the actor, event, repository, or public key invalidates the -signed request. diff --git a/docs/participant/submissions.md b/docs/participant/submissions.md deleted file mode 100644 index 526ead3..0000000 --- a/docs/participant/submissions.md +++ /dev/null @@ -1,28 +0,0 @@ -# Submissions - -Prepare the encrypted bundle locally and commit only the public bundle and -signed request metadata. Never commit plaintext, private signing keys, or judge -secrets. - -Push to a branch in the participant fork and open a PR to upstream `main`: - -```text -requests/submissions//request.json -requests/submissions//bundle.eventctl -``` - -`request.json` is a repository wrapper around the complete signed `eventctl -submission prepare` document. It mirrors the attempt, actor, fork repository, -PR number/ID, head branch/SHA, and bundle digest so the trusted check can -compare every field with the immutable GitHub PR API response. - -The read-only intake check binds the authenticated PR actor, active team from -`registry`, attempt ID, immutable PR/head identity, and bundle/request digests. -The organizer then runs `eventctl submission verify-request` against the -protected identity/configuration files and the bundle. No participant code is -executed and the bundle is never decrypted by intake. - -An organizer reviews a valid check and merges an admin state PR into `registry`. -The idempotency key is `(team_id, attempt_id)`. A retry of the same attempt -returns the existing result; a new attempt ID intentionally permits the same -content to be submitted again by the same authorized participant. diff --git a/docs/participant/team-formation.md b/docs/participant/team-formation.md deleted file mode 100644 index 118f61a..0000000 --- a/docs/participant/team-formation.md +++ /dev/null @@ -1,68 +0,0 @@ -# Team formation - -Team formation is a public, two-stage request. Anyone can read the proposal and -proof files; only the organizer's reviewed merge can activate membership. - -## Stage 1: proposal - -The proposer must be one of the listed members. Generate a canonical proposal -with the event's participant tooling and place it in: - -```text -requests/teams//team.json -``` - -The proposal must contain a fresh registration ID, a UUID team ID, the exact -event/repository binding, an expiry, and a numerically sorted list of unique -members. Each member entry contains the numeric GitHub ID and the public key -that member will prove. `team.json` also embeds the exact `eventctl team -propose` document; the organizer re-verifies that embedded document with the -protected event configuration and identity registry. - -Each member's `eventctl team consent` signs the embedded eventctl proposal. -The proposer places the deterministic sorted signature container (containing -the complete consent documents) at: - -```text -requests/teams//signatures.json -``` - -The current starter uses an explicit Ed25519 multi-signature container. It is -not a BLS aggregate and must not be described as one. - -Open a PR to upstream `main`. The check can report: - -```text -PENDING_KEY_PROOFS -missing: 3 numeric member IDs -``` - -It must not reserve a member, team name, quota, or leaderboard entry. - -## Stage 2: one proof PR per member - -Each member opens a PR from their own fork containing exactly one file: - -```text -requests/teams//proofs/.json -``` - -The proof is a `pythonhk.key-proof/v2` wrapper containing that member's complete -`eventctl identity register` document and complete `eventctl team consent` -document. The trusted workflow compares the file's claimed ID with -`github.event.pull_request.user.id`, and the organizer later runs both eventctl -verifiers at the immutable PR source times. - -Copying another member's proof fails at this comparison. Changing the claimed -ID invalidates the binding signature. Changing the proposal or public key -invalidates the proof's digest binding. - -When all proofs pass, the proposal check becomes `READY_TO_ACTIVATE`. It still -is not active until the organizer creates and merges an activation PR into -`registry`. - -## Failure and expiry - -Pending proposals may be rejected or expire. They do not block another proposal -containing the same member. The first fully verified activation that succeeds -against the current registry wins. diff --git a/event/binding.json b/event/binding.json new file mode 100644 index 0000000..c286ea6 --- /dev/null +++ b/event/binding.json @@ -0,0 +1 @@ +{"v":2,"kind":"event-binding","protocol":"eventctl/v2","event_id":"starter-event-2026","event_epoch":1,"repository_id":"1","valid_from":"2026-01-01T00:00:00Z","valid_until":"2027-01-01T00:00:00Z","terms_sha256":"25f479ba7d33c062e519cd23c59c7b8df5701f85dd00b00b16ee7e3804792105","ttl_seconds":{"registration":604800,"team":604800,"submission":86400},"limits":{"team_min":1,"team_max":5,"attempts_per_team":10,"attempts_total":200}} diff --git a/event/event.example.yaml b/event/event.example.yaml deleted file mode 100644 index 94ed21e..0000000 --- a/event/event.example.yaml +++ /dev/null @@ -1,30 +0,0 @@ -schema: pythonhk.event-config/v2 -event_id: replace-with-event-id -event_epoch: "1" -phase: draft -enabled: false -base_repository: - owner: pythonhk - name: replace-with-event-repository - id: "0000000000000000001" -terms: - path: event/terms.md - sha256: replace-with-terms-digest -registration: - request_ttl_seconds: 604800 - maximum_participants: 200 -teams: - minimum_size: 1 - maximum_size: 5 - proposal_ttl_seconds: 604800 -submissions: - maximum_attempts_per_team: 10 - maximum_total_attempts: 200 - base_branch: main -state: - branch: registry - schema: pythonhk.registry-state/v2 - journal_format: hash-linked-json-v2 -scoring: - mode: external_judge - enabled: false diff --git a/event/terms.example.md b/event/terms.example.md deleted file mode 100644 index b6285ad..0000000 --- a/event/terms.example.md +++ /dev/null @@ -1,8 +0,0 @@ -# Participant terms - -Replace this file during organizer bootstrap. Its exact UTF-8/LF bytes are -hashed into the signed event configuration. - -The event is public and mutually distrustful: request files, Git history, PRs, -and normalized public registry records may be visible to every participant. -Never commit private keys, plaintext confidential submissions, or secrets. diff --git a/event/terms.md b/event/terms.md new file mode 100644 index 0000000..38ecce3 --- /dev/null +++ b/event/terms.md @@ -0,0 +1,4 @@ +# Starter event terms + +Replace this document with the terms that apply to the new event. Update the +SHA-256 value in `event/binding.json` in the same reviewed organizer change. diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..bb230f8 --- /dev/null +++ b/mise.toml @@ -0,0 +1,10 @@ +min_version = "2026.6.14" + +[tools] +go = "1.26" +python = "3.12" +uv = "0.9.2" + +[tasks.test] +description = "Run the GitHub workflow black-box E2E suite" +run = "uv run --python 3.12 --with pytest==8.3.5 --no-project pytest -q tests/e2e" diff --git a/protocol/schemas/v2/identity-registry.schema.json b/protocol/schemas/v2/identity-registry.schema.json deleted file mode 100644 index 3d4b325..0000000 --- a/protocol/schemas/v2/identity-registry.schema.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://pythonhk.org/event/protocol/v2/identity-registry.schema.json", - "type": "object", - "additionalProperties": false, - "required": ["schema", "identities"], - "properties": { - "schema": {"const": "pythonhk.eventctl/identity-registry/v1"}, - "identities": { - "type": "array", - "maxItems": 1000, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["actor_id", "key_epoch", "identity"], - "properties": { - "actor_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "key_epoch": {"type": "string", "pattern": "^[1-9][0-9]{0,9}$"}, - "identity": { - "type": "object", - "additionalProperties": false, - "required": ["algorithm", "key_id", "public_key"], - "properties": { - "algorithm": {"const": "Ed25519"}, - "key_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "public_key": {"type": "string", "minLength": 1} - } - } - } - } - } - } -} diff --git a/protocol/schemas/v2/key-proof.schema.json b/protocol/schemas/v2/key-proof.schema.json deleted file mode 100644 index ea59d54..0000000 --- a/protocol/schemas/v2/key-proof.schema.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://pythonhk.org/event/protocol/v2/key-proof.schema.json", - "type": "object", - "additionalProperties": false, - "required": ["schema", "registration_id", "registration", "consent"], - "properties": { - "schema": {"const": "pythonhk.key-proof/v2"}, - "registration_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "registration": {"type": "object", "minProperties": 1, "maxProperties": 32}, - "consent": {"type": "object", "minProperties": 1, "maxProperties": 32} - } -} diff --git a/protocol/schemas/v2/registration-request.schema.json b/protocol/schemas/v2/registration-request.schema.json deleted file mode 100644 index b09d072..0000000 --- a/protocol/schemas/v2/registration-request.schema.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://pythonhk.org/event/protocol/v2/registration-request.schema.json", - "type": "object", - "additionalProperties": true, - "required": ["kind", "actor_id", "key_id", "key_epoch", "event_id", "base_repository", "participant_key", "signature"], - "properties": { - "kind": {"const": "registration_request"}, - "actor_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "key_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "key_epoch": {"type": "string", "pattern": "^[1-9][0-9]{0,9}$"}, - "event_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{2,63}$"}, - "base_repository": {"type": "object", "required": ["id"]}, - "participant_key": {"type": "object", "required": ["key_id", "public_key"]}, - "signature": {"type": "object", "required": ["key_id"]} - } -} diff --git a/protocol/schemas/v2/registry-state.schema.json b/protocol/schemas/v2/registry-state.schema.json deleted file mode 100644 index 71d7949..0000000 --- a/protocol/schemas/v2/registry-state.schema.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://pythonhk.org/event/protocol/v2/registry-state.schema.json", - "type": "object", - "additionalProperties": false, - "required": ["schema", "event_id", "phase", "enabled", "disabled_reason", "revision", "users", "teams", "memberships", "attempts", "quotas", "results", "scoring"], - "properties": { - "schema": {"const": "pythonhk.registry-state/v2"}, - "event_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{2,63}$"}, - "phase": {"enum": ["draft", "registration_open", "formation_open", "submissions_open", "frozen", "closed", "archived"]}, - "enabled": {"type": "boolean"}, - "disabled_reason": {"type": "string", "maxLength": 512}, - "lifecycle_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, - "revision": {"type": "integer", "minimum": 0}, - "users": {"type": "object", "additionalProperties": {"$ref": "#/$defs/user"}}, - "teams": {"type": "object", "additionalProperties": {"$ref": "#/$defs/team"}}, - "memberships": {"type": "object", "additionalProperties": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}}, - "attempts": {"type": "object", "additionalProperties": {"$ref": "#/$defs/attempt"}} - ,"quotas": {"$ref": "#/$defs/quotas"} - ,"results": {"type": "object", "additionalProperties": {"$ref": "#/$defs/result"}} - ,"scoring": {"$ref": "#/$defs/scoring"} - }, - "$defs": { - "user": { - "type": "object", "additionalProperties": false, - "required": ["github_id", "key_id", "public_key", "proof_digest"], - "properties": { - "github_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "key_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "public_key": {"type": "string"}, - "proof_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "registration_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"} - } - }, - "team": { - "type": "object", "additionalProperties": false, - "required": ["team_id", "member_ids", "proposal_digest", "activation_commit"], - "properties": { - "team_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "member_ids": {"type": "array", "minItems": 1, "maxItems": 64, "items": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}}, - "proposal_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "manifest_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "activation_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, - "proof_digests": { - "type": "object", - "additionalProperties": {"type": "string", "pattern": "^[0-9a-f]{64}$"} - } - } - }, - "attempt": { - "type": "object", "additionalProperties": false, - "required": ["team_id", "github_id", "bundle_sha256", "status", "source_pr"], - "properties": { - "team_id": {"type": "string"}, - "github_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "bundle_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "request_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "fork_repository_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "head_branch": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$"}, - "head_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, - "status": {"enum": ["reserved", "completed", "rejected", "cancelled"]}, - "source_pr": {"type": "integer", "minimum": 1}, - "source_pr_id": {"type": "string", "pattern": "^[1-9][0-9]{0,31}$"} - } - }, - "quotas": { - "type": "object", "additionalProperties": false, - "required": ["maximum_total_attempts", "maximum_attempts_per_team", "reserved_total", "reserved_by_team"], - "properties": { - "maximum_total_attempts": {"type": "integer", "minimum": 0}, - "maximum_attempts_per_team": {"type": "integer", "minimum": 0}, - "reserved_total": {"type": "integer", "minimum": 0}, - "reserved_by_team": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}} - } - }, - "result": { - "type": "object", "additionalProperties": false, - "required": ["attempt_id", "team_id", "status", "payload_sha256", "scorer_id", "scorer_version", "source_commit"], - "properties": { - "attempt_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "team_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "status": {"enum": ["accepted", "rejected", "error"]}, - "payload_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "scorer_id": {"type": "string", "minLength": 1, "maxLength": 64}, - "scorer_version": {"type": "string", "minLength": 1, "maxLength": 64}, - "source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"} - } - }, - "scoring": { - "type": "object", "additionalProperties": false, - "required": ["enabled", "isolation", "scorer_id", "scorer_version"], - "properties": { - "enabled": {"type": "boolean"}, - "isolation": {"const": "organizer-controlled"}, - "scorer_id": {"type": "string", "maxLength": 64}, - "scorer_version": {"type": "string", "maxLength": 64} - } - } - } -} diff --git a/protocol/schemas/v2/scoring-result.schema.json b/protocol/schemas/v2/scoring-result.schema.json deleted file mode 100644 index 0d40006..0000000 --- a/protocol/schemas/v2/scoring-result.schema.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://pythonhk.org/event/protocol/v2/scoring-result.schema.json", - "type": "object", - "additionalProperties": false, - "required": ["schema", "event_id", "attempt_id", "team_id", "status", "payload_sha256", "scorer_id", "scorer_version", "source_attempt_digest", "issued_at"], - "properties": { - "schema": {"const": "pythonhk.scoring-result/v2"}, - "event_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{2,63}$"}, - "attempt_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "team_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "status": {"enum": ["accepted", "rejected", "error"]}, - "payload_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "scorer_id": {"type": "string", "pattern": "^[A-Za-z0-9._-]{1,64}$"}, - "scorer_version": {"type": "string", "pattern": "^[A-Za-z0-9._+-]{1,64}$"}, - "source_attempt_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "issued_at": {"type": "string", "format": "date-time"} - } -} diff --git a/protocol/schemas/v2/submission-request.schema.json b/protocol/schemas/v2/submission-request.schema.json deleted file mode 100644 index 97eac51..0000000 --- a/protocol/schemas/v2/submission-request.schema.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://pythonhk.org/event/protocol/v2/submission-request.schema.json", - "type": "object", - "additionalProperties": false, - "required": ["schema", "event_id", "repository_id", "team_id", "attempt_id", "github_id", "pr_number", "pr_id", "fork_repository_id", "head_owner", "head_branch", "head_sha", "bundle_sha256", "eventctl_request", "issued_at", "expires_at"], - "properties": { - "schema": {"const": "pythonhk.submission-request/v2"}, - "event_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{2,63}$"}, - "repository_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "team_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "attempt_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "github_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "pr_number": {"type": "integer", "minimum": 1}, - "pr_id": {"type": "string", "pattern": "^[1-9][0-9]{0,31}$"}, - "fork_repository_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "head_owner": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}$"}, - "head_branch": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$"}, - "head_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, - "bundle_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "eventctl_request": {"type": "object", "minProperties": 1, "maxProperties": 48}, - "issued_at": {"type": "string", "format": "date-time"}, - "expires_at": {"type": "string", "format": "date-time"} - } -} diff --git a/protocol/schemas/v2/team-proposal.schema.json b/protocol/schemas/v2/team-proposal.schema.json deleted file mode 100644 index 51f1ee0..0000000 --- a/protocol/schemas/v2/team-proposal.schema.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://pythonhk.org/event/protocol/v2/team-proposal.schema.json", - "type": "object", - "additionalProperties": false, - "required": ["schema", "event_id", "repository_id", "registration_id", "team_id", "proposer_github_id", "members", "eventctl_proposal", "issued_at", "expires_at"], - "properties": { - "schema": {"const": "pythonhk.team-proposal/v2"}, - "event_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{2,63}$"}, - "repository_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "registration_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "team_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "proposer_github_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "members": { - "type": "array", - "minItems": 1, - "maxItems": 64, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["github_id", "key_id", "public_key", "role"], - "properties": { - "github_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "key_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "public_key": {"type": "string", "minLength": 1, "maxLength": 256}, - "role": {"enum": ["captain", "member"]} - } - } - }, - "eventctl_proposal": { - "type": "object", - "minProperties": 1, - "maxProperties": 32 - }, - "issued_at": {"type": "string", "format": "date-time"}, - "expires_at": {"type": "string", "format": "date-time"} - } -} diff --git a/protocol/schemas/v2/team-signatures.schema.json b/protocol/schemas/v2/team-signatures.schema.json deleted file mode 100644 index 7969bec..0000000 --- a/protocol/schemas/v2/team-signatures.schema.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://pythonhk.org/event/protocol/v2/team-signatures.schema.json", - "type": "object", - "additionalProperties": false, - "required": ["schema", "registration_id", "proposal_sha256", "algorithm", "signatures"], - "properties": { - "schema": {"const": "pythonhk.team-signatures/v2"}, - "registration_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, - "proposal_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "algorithm": {"const": "Ed25519-multisignature-v1"}, - "signatures": { - "type": "array", - "minItems": 1, - "maxItems": 64, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["github_id", "key_id", "consent"], - "properties": { - "github_id": {"type": "string", "pattern": "^[1-9][0-9]{0,19}$"}, - "key_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "consent": {"type": "object", "minProperties": 1, "maxProperties": 32} - } - } - } - } -} diff --git a/registry/identity-registry.json b/registry/identity-registry.json deleted file mode 100644 index c4efcb4..0000000 --- a/registry/identity-registry.json +++ /dev/null @@ -1 +0,0 @@ -{"identities":[],"schema":"pythonhk.eventctl/identity-registry/v1"} diff --git a/registry/memberships/index.json b/registry/memberships/index.json deleted file mode 100644 index 8b7675f..0000000 --- a/registry/memberships/index.json +++ /dev/null @@ -1 +0,0 @@ -{"memberships":{},"schema":"pythonhk.registry-memberships/v2"} diff --git a/registry/scoring/.keep b/registry/scoring/.keep deleted file mode 100644 index 8b13789..0000000 --- a/registry/scoring/.keep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/registry/state.json b/registry/state.json index 49c3a91..7aec180 100644 --- a/registry/state.json +++ b/registry/state.json @@ -1 +1 @@ -{"attempts":{},"disabled_reason":"bootstrap_required","enabled":false,"event_id":"replace-with-event-id","lifecycle_commit":"0000000000000000000000000000000000000000","memberships":{},"phase":"draft","quotas":{"maximum_attempts_per_team":0,"maximum_total_attempts":0,"reserved_by_team":{},"reserved_total":0},"results":{},"revision":0,"schema":"pythonhk.registry-state/v2","scoring":{"enabled":false,"isolation":"organizer-controlled","scorer_id":"","scorer_version":""},"teams":{},"users":{}} +{"v":2,"kind":"event-registry","event":{"event_id":"starter-event-2026","event_epoch":1,"repository_id":"1","binding_sha256":"91702e8516bd7112fa9b81888f9ac78723329a6186b958c9e9bc0078fe9791bb"},"revision":0,"phase":"draft","enabled":false,"disabled_reason":"bootstrap_required","identities":[],"teams":[],"attempts":[]} diff --git a/registry/submissions/index.json b/registry/submissions/index.json deleted file mode 100644 index 22af3c3..0000000 --- a/registry/submissions/index.json +++ /dev/null @@ -1 +0,0 @@ -{"attempts":{},"schema":"pythonhk.registry-submissions/v2"} diff --git a/registry/teams/index.json b/registry/teams/index.json deleted file mode 100644 index 426aa8a..0000000 --- a/registry/teams/index.json +++ /dev/null @@ -1 +0,0 @@ -{"schema":"pythonhk.registry-teams/v2","teams":{}} diff --git a/registry/users/index.json b/registry/users/index.json deleted file mode 100644 index 296930f..0000000 --- a/registry/users/index.json +++ /dev/null @@ -1 +0,0 @@ -{"schema":"pythonhk.registry-users/v2","users":{}} diff --git a/scripts/actions/registration.sh b/scripts/actions/registration.sh deleted file mode 100644 index a6fe9db..0000000 --- a/scripts/actions/registration.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'registration.sh --request FILE --actual-author ID' -} - -request='' -actual_author='' -while (($# > 0)); do - case "$1" in - --request) - (($# >= 2)) || usage - request=$2 - shift 2 - ;; - --actual-author) - (($# >= 2)) || usage - actual_author=$2 - shift 2 - ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$request" && -n "$actual_author" ]] || usage -event_require_command jq -event_require_file 'registration request' "$request" -event_assert_canonical_json "$request" -event_decimal_id "$actual_author" || event_die 'actual PR author ID is invalid' - -jq -e --arg key "$(jq -er '.key_id' "$request")" ' - .kind == "registration_request" - and (.actor_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.key_id | type == "string" and test("^[0-9a-f]{64}$")) - and (.key_epoch | type == "string" and test("^[1-9][0-9]{0,9}$")) - and (.event_id | type == "string" and test("^[a-z0-9][a-z0-9._-]{2,63}$")) - and (.base_repository.id | tostring | test("^[1-9][0-9]{0,19}$")) - and (.participant_key | type == "object" and .key_id == $key and (.public_key | type == "string" and length >= 1 and length <= 256)) - and (.signature | type == "object" and .key_id == $key) -' "$request" >/dev/null || event_die 'registration request failed strict structural validation' - -claimed_id=$(jq -er '.actor_id' "$request") -[[ "$claimed_id" == "$actual_author" ]] || event_die "registration actor mismatch: claimed $claimed_id, authenticated PR author $actual_author" -printf '%s\n' 'registration request structurally valid and actor-bound' diff --git a/scripts/actions/submission.sh b/scripts/actions/submission.sh deleted file mode 100644 index b2bf6ba..0000000 --- a/scripts/actions/submission.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'submission.sh --request FILE --actual-author ID --registry FILE [--bundle FILE --actual-pr NUMBER --actual-pr-id ID --actual-fork-repository-id ID --actual-head-owner NAME --actual-head-branch NAME --actual-head-sha SHA]' -} - -request='' -actual_author='' -registry='' -bundle='' -actual_pr='' -actual_pr_id='' -actual_fork_repository_id='' -actual_head_owner='' -actual_head_branch='' -actual_head_sha='' -while (($# > 0)); do - case "$1" in - --request) - (($# >= 2)) || usage - request=$2 - shift 2 - ;; - --actual-author) - (($# >= 2)) || usage - actual_author=$2 - shift 2 - ;; - --registry) - (($# >= 2)) || usage - registry=$2 - shift 2 - ;; - --bundle) - (($# >= 2)) || usage - bundle=$2 - shift 2 - ;; - --actual-pr) - (($# >= 2)) || usage - actual_pr=$2 - shift 2 - ;; - --actual-pr-id) - (($# >= 2)) || usage - actual_pr_id=$2 - shift 2 - ;; - --actual-fork-repository-id) - (($# >= 2)) || usage - actual_fork_repository_id=$2 - shift 2 - ;; - --actual-head-owner) - (($# >= 2)) || usage - actual_head_owner=$2 - shift 2 - ;; - --actual-head-branch) - (($# >= 2)) || usage - actual_head_branch=$2 - shift 2 - ;; - --actual-head-sha) - (($# >= 2)) || usage - actual_head_sha=$2 - shift 2 - ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$request" && -n "$actual_author" && -n "$registry" ]] || usage -if [[ -n "$actual_pr" || -n "$actual_pr_id" || -n "$actual_fork_repository_id" || -n "$actual_head_owner" || -n "$actual_head_branch" || -n "$actual_head_sha" ]]; then - [[ -n "$actual_pr" && -n "$actual_pr_id" && -n "$actual_fork_repository_id" && -n "$actual_head_owner" && -n "$actual_head_branch" && -n "$actual_head_sha" ]] || usage - [[ "$actual_pr" =~ ^[1-9][0-9]*$ && "$actual_pr_id" =~ ^[1-9][0-9]*$ && "$actual_fork_repository_id" =~ ^[1-9][0-9]*$ && "$actual_head_sha" =~ ^[0-9a-f]{40}$ ]] || event_die 'actual PR metadata is invalid' -fi -event_require_command jq -event_require_file 'submission request' "$request" -event_require_file 'registry state' "$registry" -event_assert_canonical_json "$request" -event_decimal_id "$actual_author" || event_die 'actual PR author ID is invalid' - -jq -e ' - .schema == "pythonhk.submission-request/v2" - and (.event_id | type == "string") - and (.repository_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.team_id | type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.attempt_id | type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.github_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.pr_number | type == "number" and floor == . and . >= 1) - and (.pr_id | type == "string" and test("^[1-9][0-9]{0,31}$")) - and (.fork_repository_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.head_owner | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9-]{0,38}$")) - and (.head_branch | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")) - and (.head_sha | type == "string" and test("^[0-9a-f]{40}$")) - and (.bundle_sha256 | type == "string" and test("^[0-9a-f]{64}$")) - and (.eventctl_request | type == "object" and length > 0 and length <= 48) -' "$request" >/dev/null || event_die 'submission request failed strict structural validation' - -claimed_id=$(jq -er '.github_id' "$request") -[[ "$claimed_id" == "$actual_author" ]] || event_die "submission actor mismatch: claimed $claimed_id, authenticated PR author $actual_author" - -phase=$(jq -er '.phase' "$registry") -enabled=$(jq -er '.enabled' "$registry") -[[ "$phase" == 'submissions_open' || "$phase" == 'frozen' ]] || event_die "submissions are not open in registry phase $phase" -[[ "$enabled" == 'true' ]] || event_die 'event is disabled in registry' - -team_id=$(jq -er '.team_id' "$request") -jq -e --arg id "$claimed_id" --arg team "$team_id" '.memberships[$id] == $team' "$registry" >/dev/null || \ - event_die 'authenticated actor is not an active member of the claimed team' -authoritative_team_digest=$(jq -er --arg team "$team_id" '.teams[$team].proposal_digest' "$registry") - -eventctl_request=$(mktemp "${TMPDIR:-/tmp}/eventctl-submission.XXXXXX") -trap 'rm -f -- "$eventctl_request"' EXIT -jq -e -cS '.eventctl_request' "$request" > "$eventctl_request" || event_die 'submission has no embedded eventctl request' -jq -e --arg event "$(jq -er '.event_id' "$request")" --arg repository "$(jq -er '.repository_id' "$request")" --arg actor "$claimed_id" --arg team "$team_id" --arg team_digest "$authoritative_team_digest" --arg attempt "$(jq -er '.attempt_id' "$request")" --arg pr "$(jq -er '.pr_number' "$request")" --arg pr_id "$(jq -er '.pr_id' "$request")" --arg fork "$(jq -er '.fork_repository_id' "$request")" --arg owner "$(jq -er '.head_owner' "$request")" --arg branch "$(jq -er '.head_branch' "$request")" --arg sha "$(jq -er '.head_sha' "$request")" --arg bundle_digest "$(jq -er '.bundle_sha256' "$request")" ' - .kind == "submission_envelope" - and .event_id == $event - and .actor_id == $actor - and .team_id == $team - and .attempt_id == $attempt - and (.base_repository.id | tostring) == $repository - and (.pull_request.number | tostring) == $pr - and (.pull_request.id | tostring) == $pr_id - and (.pull_request.head_repository_id | tostring) == $fork - and .pull_request.head_owner == $owner - and .pull_request.head_ref == $branch - and .pull_request.head_sha == $sha - and (.pull_request.number | numbers and . >= 1) - and (.pull_request.id | tostring | test("^[1-9][0-9]{0,31}$")) - and (.pull_request.head_owner | type == "string") - and .bundle.sha256 == $bundle_digest - and .team_proposal_digest == $team_digest - and .signature.key_id == .key_id -' "$eventctl_request" >/dev/null || event_die 'embedded eventctl submission request does not match the repository request' - -if [[ -n "$actual_pr" ]]; then - request_pr=$(jq -er '.pr_number' "$request") - request_pr_id=$(jq -er '.pr_id' "$request") - request_fork=$(jq -er '.fork_repository_id' "$request") - request_owner=$(jq -er '.head_owner' "$request") - request_branch=$(jq -er '.head_branch' "$request") - request_sha=$(jq -er '.head_sha' "$request") - [[ "$request_pr" == "$actual_pr" && "$request_pr_id" == "$actual_pr_id" && "$request_fork" == "$actual_fork_repository_id" && "$request_owner" == "$actual_head_owner" && "$request_branch" == "$actual_head_branch" && "$request_sha" == "$actual_head_sha" ]] || event_die 'submission request does not bind the immutable authenticated PR metadata' -fi - -if [[ -n "$bundle" ]]; then - event_require_file 'submission bundle' "$bundle" - actual_bundle_digest=$(event_sha256_file "$bundle") - want_bundle_digest=$(jq -er '.bundle_sha256' "$request") - [[ "$actual_bundle_digest" == "$want_bundle_digest" ]] || event_die 'bundle digest does not match signed request' -fi - -attempt_id=$(jq -er '.attempt_id' "$request") -existing_attempt=$(jq -c --arg attempt "$attempt_id" '(.attempts[$attempt] // null)' "$registry") -if [[ "$existing_attempt" != 'null' ]]; then - jq -e --arg attempt "$attempt_id" --arg team "$team_id" --arg actor "$claimed_id" --arg digest "$(jq -er '.bundle_sha256' "$request")" \ - '.attempts[$attempt].team_id == $team and .attempts[$attempt].github_id == $actor and .attempts[$attempt].bundle_sha256 == $digest' "$registry" >/dev/null || \ - event_die 'attempt_id is already bound to a different actor, team, or bundle' - printf '%s\n' 'exact submission replay matches the existing authoritative attempt' - exit 0 -fi - -printf '%s\n' 'submission request structurally valid, actor-bound, active-team-bound, and unseen' diff --git a/scripts/actions/team-proof.sh b/scripts/actions/team-proof.sh deleted file mode 100644 index ca830ce..0000000 --- a/scripts/actions/team-proof.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'team-proof.sh --proposal FILE --proof FILE --actual-author ID' -} - -proposal='' -proof='' -actual_author='' -while (($# > 0)); do - case "$1" in - --proposal) - (($# >= 2)) || usage - proposal=$2 - shift 2 - ;; - --proof) - (($# >= 2)) || usage - proof=$2 - shift 2 - ;; - --actual-author) - (($# >= 2)) || usage - actual_author=$2 - shift 2 - ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$proposal" && -n "$proof" && -n "$actual_author" ]] || usage -event_require_command jq -event_require_file 'team proposal' "$proposal" -event_require_file 'member key proof' "$proof" -event_assert_canonical_json "$proposal" -event_assert_canonical_json "$proof" -event_decimal_id "$actual_author" || event_die 'actual PR author ID is invalid' - -jq -e ' - .schema == "pythonhk.key-proof/v2" - and (.registration_id | type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.registration | type == "object" and length > 0 and length <= 32) - and (.consent | type == "object" and length > 0 and length <= 32) -' "$proof" >/dev/null || event_die 'member key proof failed strict structural validation' - -claimed_id=$(jq -er '.registration.actor_id' "$proof") -[[ "$claimed_id" == "$actual_author" ]] || event_die "proof actor mismatch: claimed $claimed_id, authenticated PR author $actual_author" - -proposal_registration=$(jq -er '.registration_id' "$proposal") -proof_registration=$(jq -er '.registration_id' "$proof") -[[ "$proposal_registration" == "$proof_registration" ]] || event_die 'proof is bound to a different registration' - -jq -e --arg id "$claimed_id" 'any(.members[]; .github_id == $id)' "$proposal" >/dev/null || \ - event_die 'proof actor is not a member of the proposal' - -proposal_key=$(jq -er --arg id "$claimed_id" '.members[] | select(.github_id == $id) | .key_id' "$proposal") -proposal_public_key=$(jq -er --arg id "$claimed_id" '.members[] | select(.github_id == $id) | .public_key' "$proposal") -proof_key=$(jq -er '.registration.key_id' "$proof") -[[ "$proposal_key" == "$proof_key" ]] || event_die 'proof key does not match the exact proposal member key' - -eventctl_proposal=$(mktemp "${TMPDIR:-/tmp}/eventctl-proposal.XXXXXX") -trap 'rm -f -- "$eventctl_proposal"' EXIT -jq -e -cS '.eventctl_proposal' "$proposal" > "$eventctl_proposal" || event_die 'proposal has no embedded eventctl proposal' -eventctl_proposal_digest=$(event_json_digest "$eventctl_proposal") -event_id=$(jq -er '.event_id' "$proposal") -repository_id=$(jq -er '.repository_id' "$proposal") -team_id=$(jq -er '.team_id' "$proposal") -jq -e --arg event "$event_id" --arg repository "$repository_id" --arg id "$claimed_id" --arg team "$team_id" --arg proposal_digest "$eventctl_proposal_digest" --arg key "$proposal_key" --arg public_key "$proposal_public_key" ' - .registration.kind == "registration_request" - and (.registration.actor_id | test("^[1-9][0-9]{0,19}$")) - and .registration.actor_id == $id - and .registration.event_id == $event - and (.registration.base_repository.id | tostring) == $repository - and .registration.key_id == $key - and .registration.participant_key.key_id == $key - and .registration.participant_key.public_key == $public_key - and .registration.signature.key_id == $key - and .consent.kind == "team_consent" - and .consent.actor_id == $id - and .consent.event_id == $event - and (.consent.base_repository.id | tostring) == $repository - and .consent.team_id == $team - and .consent.key_id == $key - and .consent.signature.key_id == $key - and .consent.proposal_digest == $proposal_digest -' "$proof" >/dev/null || event_die 'member proof does not bind event, repository, team, proposal digest, and exact key' - -printf '%s\n' 'key proof structurally valid and actor-bound' diff --git a/scripts/actions/team-proposal.sh b/scripts/actions/team-proposal.sh deleted file mode 100644 index 0780c44..0000000 --- a/scripts/actions/team-proposal.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'team-proposal.sh --proposal FILE --signatures FILE [--status-out FILE]' -} - -proposal='' -signatures='' -status_out='' -while (($# > 0)); do - case "$1" in - --proposal) - (($# >= 2)) || usage - proposal=$2 - shift 2 - ;; - --signatures) - (($# >= 2)) || usage - signatures=$2 - shift 2 - ;; - --status-out) - (($# >= 2)) || usage - status_out=$2 - shift 2 - ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$proposal" && -n "$signatures" ]] || usage -event_require_command jq -event_require_file 'team proposal' "$proposal" -event_require_file 'team signature envelope' "$signatures" -event_require_command mktemp -event_assert_canonical_json "$proposal" -event_assert_canonical_json "$signatures" - -jq -e ' - .schema == "pythonhk.team-proposal/v2" - and (.event_id | type == "string" and test("^[a-z0-9][a-z0-9._-]{2,63}$")) - and (.repository_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.registration_id | type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.team_id | type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.proposer_github_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.members | type == "array" and length >= 1 and length <= 64) - and (.members | all(.[ ]; - type == "object" - and (keys_unsorted | sort) == ["github_id", "key_id", "public_key", "role"] - and (.github_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.key_id | type == "string" and test("^[0-9a-f]{64}$")) - and (.public_key | type == "string" and length >= 1 and length <= 256) - and (.role == "captain" or .role == "member") - )) - and ([.members[].github_id] | all(test("^[1-9][0-9]{0,19}$")) and length == (unique | length) and . == (sort_by(tonumber))) - and ([.members[].key_id] | all(test("^[0-9a-f]{64}$")) and length == (unique | length)) - and ([.members[].role] | map(select(. == "captain")) | length == 1) - and (.eventctl_proposal | type == "object" and length > 0 and length <= 32) - and (.issued_at | type == "string") - and (.expires_at | type == "string") -' "$proposal" >/dev/null || event_die 'team proposal failed strict structural validation' - -proposer_id=$(jq -er '.proposer_github_id' "$proposal") -jq -e --arg proposer "$proposer_id" 'any(.members[]; .github_id == $proposer)' "$proposal" >/dev/null || \ - event_die 'team proposal proposer is not listed as a member' - -proposal_digest=$(event_json_digest "$proposal") -registration_id=$(jq -er '.registration_id' "$proposal") -eventctl_proposal=$(mktemp "${TMPDIR:-/tmp}/eventctl-proposal.XXXXXX") -trap 'rm -f -- "$eventctl_proposal"' EXIT -jq -e -cS '.eventctl_proposal' "$proposal" > "$eventctl_proposal" || event_die 'team proposal does not contain a valid eventctl proposal object' -eventctl_proposal_digest=$(event_json_digest "$eventctl_proposal") -proposer_key=$(jq -er --arg proposer "$proposer_id" '.members[] | select(.github_id == $proposer) | .key_id' "$proposal") -jq -e \ - --arg event "$(jq -er '.event_id' "$proposal")" \ - --arg repository "$(jq -er '.repository_id' "$proposal")" \ - --arg registration "$registration_id" \ - --arg team "$(jq -er '.team_id' "$proposal")" \ - --arg proposer "$(jq -er '.proposer_github_id' "$proposal")" \ - --arg proposer_key "$proposer_key" \ - --argjson members "$(jq -c '.members | map(.github_id)' "$proposal")" \ - ' - .kind == "team_proposal" - and .operation_id == $registration - and .event_id == $event - and (.base_repository.id | tostring) == $repository - and .team_id == $team - and .proposer_actor_id == $proposer - and .key_id == $proposer_key - and .member_actor_ids == $members -' "$eventctl_proposal" >/dev/null || event_die 'embedded eventctl proposal does not match the repository team manifest' - -jq -e --arg digest "$proposal_digest" --arg eventctl_digest "$eventctl_proposal_digest" --arg registration "$registration_id" --arg event "$(jq -er '.event_id' "$proposal")" --arg repository "$(jq -er '.repository_id' "$proposal")" --arg team "$(jq -er '.team_id' "$proposal")" --argjson members "$(jq -c '.members' "$proposal")" ' - .schema == "pythonhk.team-signatures/v2" - and .algorithm == "Ed25519-multisignature-v1" - and .registration_id == $registration - and .proposal_sha256 == $digest - and (.signatures | type == "array" and length >= 1 and length <= 64) - and ([.signatures[].github_id] | length == (unique | length) and . == (sort_by(tonumber))) - and (.signatures | all(.[]; - . as $sig - | (keys_unsorted | sort) == ["consent", "github_id", "key_id"] - and ($sig.github_id | test("^[1-9][0-9]{0,19}$")) - and ($sig.key_id | test("^[0-9a-f]{64}$")) - and ($sig.consent | type == "object" and length > 0 and length <= 32) - and ($sig.consent.kind == "team_consent") - and ($sig.consent.actor_id == $sig.github_id) - and ($sig.consent.event_id == $event) - and (($sig.consent.base_repository.id | tostring) == $repository) - and ($sig.consent.key_id == $sig.key_id) - and ($sig.consent.signature.key_id == $sig.key_id) - and ($sig.consent.team_id == $team) - and ($sig.consent.proposal_digest == $eventctl_digest) - and any($members[]; .github_id == $sig.github_id and .key_id == $sig.key_id) - )) -' "$signatures" >/dev/null || event_die 'team signature envelope failed structural validation or does not bind the proposal' - -member_count=$(jq -er '.members | length' "$proposal") -signature_count=$(jq -er '.signatures | length' "$signatures") -if ((signature_count == member_count)); then - member_keys=$(jq -cS '[.members[] | {github_id, key_id}]' "$proposal") - signature_keys=$(jq -cS '[.signatures[] | {github_id, key_id}]' "$signatures") - [[ "$member_keys" == "$signature_keys" ]] || event_die 'complete signature envelope is not exactly the sorted proposal members' -fi -missing_count=$((member_count - signature_count)) -if ((signature_count < member_count)); then - status='PENDING_KEY_PROOFS' - message="${signature_count}/${member_count} member signatures are present; proof PRs remain required" -else - status='READY_TO_ACTIVATE' - message='all proposal signatures are present; organizer activation is still required' -fi - -if [[ -n "$status_out" ]]; then - jq -n --arg status "$status" --arg message "$message" --argjson missing "$missing_count" \ - '{status:$status, missing_signature_count:$missing, message:$message}' > "$status_out" -fi -printf '%s\n' "$status" diff --git a/scripts/admin/activate-team.sh b/scripts/admin/activate-team.sh deleted file mode 100644 index f34725a..0000000 --- a/scripts/admin/activate-team.sh +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'activate-team.sh --state FILE --proposal FILE --signatures FILE --proof-dir DIR --commit SHA --out FILE [--proof-source-times FILE --source-time RFC3339 --eventctl PATH --config FILE --authority FILE --state-meta FILE --identity-registry FILE] [--plan-only]' -} - -state='' -proposal='' -signatures='' -proof_dir='' -commit_sha='' -output='' -eventctl='' -config='' -authority='' -state_meta='' -identity_registry='' -source_time='' -proof_source_times='' -plan_only=false -while (($# > 0)); do - case "$1" in - --state) - (($# >= 2)) || usage - state=$2 - shift 2 - ;; - --proposal) - (($# >= 2)) || usage - proposal=$2 - shift 2 - ;; - --signatures) - (($# >= 2)) || usage - signatures=$2 - shift 2 - ;; - --proof-dir) - (($# >= 2)) || usage - proof_dir=$2 - shift 2 - ;; - --commit) - (($# >= 2)) || usage - commit_sha=$2 - shift 2 - ;; - --out) - (($# >= 2)) || usage - output=$2 - shift 2 - ;; - --eventctl) - (($# >= 2)) || usage - eventctl=$2 - shift 2 - ;; - --config) - (($# >= 2)) || usage - config=$2 - shift 2 - ;; - --authority) - (($# >= 2)) || usage - authority=$2 - shift 2 - ;; - --state-meta) - (($# >= 2)) || usage - state_meta=$2 - shift 2 - ;; - --identity-registry) - (($# >= 2)) || usage - identity_registry=$2 - shift 2 - ;; - --source-time) - (($# >= 2)) || usage - source_time=$2 - shift 2 - ;; - --proof-source-times) - (($# >= 2)) || usage - proof_source_times=$2 - shift 2 - ;; - --plan-only) - plan_only=true - shift - ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$state" && -n "$proposal" && -n "$signatures" && -n "$proof_dir" && -n "$commit_sha" && -n "$output" ]] || usage -[[ "$commit_sha" =~ ^[0-9a-f]{40}$ ]] || event_die 'activation commit must be a 40-character lowercase SHA' -if [[ "$plan_only" == false ]]; then - [[ -n "$eventctl" && -n "$config" && -n "$authority" && -n "$state_meta" && -n "$identity_registry" && -n "$source_time" && -n "$proof_source_times" ]] || \ - event_usage 'normal activation requires eventctl trust context and proof source times; use --plan-only only for a local structural plan' -fi - -event_require_command jq -event_require_file 'registry state' "$state" -event_require_file 'team proposal' "$proposal" -event_require_file 'team signature envelope' "$signatures" -event_require_directory 'proof directory' "$proof_dir" -event_assert_canonical_json "$state" -event_assert_canonical_json "$proposal" -event_assert_canonical_json "$signatures" - -jq -e ' - .schema == "pythonhk.registry-state/v2" - and (.event_id | type == "string") - and (.phase | type == "string") - and (.enabled | type == "boolean") - and (.revision | type == "number" and floor == . and . >= 0) - and (.users | type == "object") - and (.teams | type == "object") - and (.memberships | type == "object") - and (.attempts | type == "object") -' "$state" >/dev/null || event_die 'registry state failed strict structural validation' - -bash "$SCRIPT_DIR/../actions/team-proposal.sh" --proposal "$proposal" --signatures "$signatures" >/dev/null - -phase=$(jq -er '.phase' "$state") -enabled=$(jq -er '.enabled' "$state") -[[ "$phase" == 'formation_open' ]] || event_die "team activation requires formation_open, got $phase" -[[ "$enabled" == 'true' ]] || event_die 'event is disabled' - -event_id=$(jq -er '.event_id' "$proposal") -state_event_id=$(jq -er '.event_id' "$state") -[[ "$event_id" == "$state_event_id" ]] || event_die 'proposal and registry belong to different events' -team_id=$(jq -er '.team_id' "$proposal") -jq -e --arg team "$team_id" '(.teams[$team] // null) == null' "$state" >/dev/null || event_die 'team ID is already active' - -member_count=$(jq -er '.members | length' "$proposal") -signature_count=$(jq -er '.signatures | length' "$signatures") -[[ "$member_count" -eq "$signature_count" ]] || event_die 'activation requires one signature entry for every member' - -proof_digests='{}' -for member_id in $(jq -er '.members[].github_id' "$proposal"); do - proof_file="$proof_dir/${member_id}.json" - event_require_file "key proof for $member_id" "$proof_file" - bash "$SCRIPT_DIR/../actions/team-proof.sh" --proposal "$proposal" --proof "$proof_file" --actual-author "$member_id" >/dev/null - proof_digest=$(event_json_digest "$proof_file") - proof_digests=$(jq -c --arg id "$member_id" --arg digest "$proof_digest" '. + {($id):$digest}' <<< "$proof_digests") -done - -member_ids=$(jq -er -c '.members | map(.github_id)' "$proposal") -signature_ids=$(jq -er -c '.signatures | map(.github_id)' "$signatures") -[[ "$member_ids" == "$signature_ids" ]] || event_die 'signature entries are not exactly the sorted proposal members' - -for member_id in $(jq -er '.members[].github_id' "$proposal"); do - jq -e --arg id "$member_id" '(.memberships[$id] // null) == null' "$state" >/dev/null || \ - event_die "member $member_id is already active in another team" -done - -if [[ "$plan_only" == false ]]; then - event_require_file 'eventctl binary' "$eventctl" - [[ -x "$eventctl" ]] || event_die "eventctl binary is not executable: $eventctl" - event_require_file 'signed event config' "$config" - event_require_file 'protected organizer authority' "$authority" - event_require_file 'protected state metadata' "$state_meta" - event_require_file 'eventctl identity registry' "$identity_registry" - event_require_file 'proof source-time metadata' "$proof_source_times" - event_assert_canonical_json "$proof_source_times" - jq -e --argjson ids "$member_ids" ' - . as $root - | type == "object" - and all($ids[]; . as $id | $root[$id] | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T")) - ' "$proof_source_times" >/dev/null || event_die 'proof source-time metadata must map every member ID to an RFC3339 time' - - proposal_doc=$(mktemp "${TMPDIR:-/tmp}/eventctl-proposal.XXXXXX") - verified_proposal=$(mktemp "${TMPDIR:-/tmp}/verified-proposal.XXXXXX") - trap 'rm -f -- "$proposal_doc" "$verified_proposal"' EXIT - jq -e -cS '.eventctl_proposal' "$proposal" > "$proposal_doc" || event_die 'cannot extract embedded eventctl proposal' - "$eventctl" team verify \ - --config "$config" --authority "$authority" --state-meta "$state_meta" --registry "$identity_registry" \ - --request "$proposal_doc" --source-time "$source_time" --out "$verified_proposal" >/dev/null - - for member_id in $(jq -er '.members[].github_id' "$proposal"); do - proof_file="$proof_dir/${member_id}.json" - registration_doc=$(mktemp "${TMPDIR:-/tmp}/eventctl-registration.XXXXXX") - consent_doc=$(mktemp "${TMPDIR:-/tmp}/eventctl-consent.XXXXXX") - verified_registration=$(mktemp "${TMPDIR:-/tmp}/verified-registration.XXXXXX") - verified_consent=$(mktemp "${TMPDIR:-/tmp}/verified-consent.XXXXXX") - jq -e -cS '.registration' "$proof_file" > "$registration_doc" - jq -e -cS '.consent' "$proof_file" > "$consent_doc" - member_source_time=$(jq -er --arg id "$member_id" '.[$id]' "$proof_source_times") - "$eventctl" identity verify \ - --config "$config" --authority "$authority" --state-meta "$state_meta" \ - --request "$registration_doc" --expect-actor-id "$member_id" --source-time "$member_source_time" \ - --out "$verified_registration" >/dev/null - "$eventctl" team verify \ - --config "$config" --authority "$authority" --state-meta "$state_meta" --registry "$identity_registry" \ - --request "$consent_doc" --proposal "$verified_proposal" --source-time "$member_source_time" \ - --out "$verified_consent" >/dev/null - jq -e --arg id "$member_id" --slurpfile proof_consent "$consent_doc" \ - 'any(.signatures[]; .github_id == $id and .consent == $proof_consent[0])' "$signatures" >/dev/null || \ - event_die "signature envelope consent does not match proof $member_id" - rm -f -- "$registration_doc" "$consent_doc" "$verified_registration" "$verified_consent" - done -fi - -revision=$(jq -er '.revision' "$state") -new_revision=$((revision + 1)) -manifest_digest=$(event_json_digest "$proposal") -eventctl_proposal_file=$(mktemp "${TMPDIR:-/tmp}/eventctl-proposal-state.XXXXXX") -trap 'rm -f -- "$eventctl_proposal_file"' EXIT -jq -e -cS '.eventctl_proposal' "$proposal" > "$eventctl_proposal_file" -proposal_digest=$(event_json_digest "$eventctl_proposal_file") -tmp_output=$(mktemp "${TMPDIR:-/tmp}/event-activated-state.XXXXXX") - -jq --arg team "$team_id" --arg commit "$commit_sha" --arg proposal_digest "$proposal_digest" --arg manifest_digest "$manifest_digest" --argjson revision "$new_revision" --argjson proof_digests "$proof_digests" \ - --slurpfile proposal_doc "$proposal" \ - ' .revision = $revision - | .teams[$team] = { - team_id: $team, - member_ids: ($proposal_doc[0].members | map(.github_id)), - proposal_digest: $proposal_digest, - manifest_digest: $manifest_digest, - activation_commit: $commit, - proof_digests: $proof_digests - } - | reduce $proposal_doc[0].members[] as $member (.; - .users[$member.github_id] = { - github_id: $member.github_id, - key_id: $member.key_id, - public_key: $member.public_key, - proof_digest: $proof_digests[$member.github_id] - } - | .memberships[$member.github_id] = $team - )' "$state" > "$tmp_output" - -jq -e -cS . "$tmp_output" > "$output" || { - rm -f -- "$tmp_output" - event_die 'failed to produce canonical activated registry state' -} -rm -f -- "$tmp_output" -if [[ "$plan_only" == true ]]; then - printf '%s\n' "planned structural activation for $team_id at registry revision $new_revision (cryptographic verification not run)" -else - printf '%s\n' "activated team $team_id at registry revision $new_revision" -fi diff --git a/scripts/admin/apply-score.sh b/scripts/admin/apply-score.sh deleted file mode 100644 index 32a5a4e..0000000 --- a/scripts/admin/apply-score.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'apply-score.sh --state FILE --result FILE --payload FILE --commit SHA --out FILE' -} -state='' -result='' -payload='' -commit_sha='' -output='' -while (($# > 0)); do - case "$1" in - --state) (($# >= 2)) || usage; state=$2; shift 2 ;; - --result) (($# >= 2)) || usage; result=$2; shift 2 ;; - --payload) (($# >= 2)) || usage; payload=$2; shift 2 ;; - --commit) (($# >= 2)) || usage; commit_sha=$2; shift 2 ;; - --out) (($# >= 2)) || usage; output=$2; shift 2 ;; - -h|--help) usage ;; - *) usage ;; - esac -done -[[ -n "$state" && -n "$result" && -n "$payload" && -n "$commit_sha" && -n "$output" ]] || usage -[[ "$commit_sha" =~ ^[0-9a-f]{40}$ ]] || event_die 'scoring source commit must be a 40-character lowercase SHA' -event_require_command jq -event_require_file 'registry state' "$state" -event_require_file 'scoring result' "$result" -event_require_file 'scoring payload' "$payload" -event_assert_canonical_json "$state" -event_assert_canonical_json "$result" -phase=$(jq -er '.phase' "$state") -[[ "$phase" == 'submissions_open' || "$phase" == 'frozen' || "$phase" == 'closed' ]] || event_die "scoring is not allowed in lifecycle phase $phase" -jq -e '.scoring.enabled == true and .scoring.isolation == "organizer-controlled"' "$state" >/dev/null || event_die 'isolated scoring is disabled' -bash "$SCRIPT_DIR/../scoring/validate-result.sh" --state "$state" --result "$result" --payload "$payload" >/dev/null -attempt_id=$(jq -er '.attempt_id' "$result") -revision=$(jq -er '.revision' "$state") -new_revision=$((revision + 1)) -payload_digest=$(jq -er '.payload_sha256' "$result") -team_id=$(jq -er '.team_id' "$result") -status=$(jq -er '.status' "$result") -scorer_id=$(jq -er '.scorer_id' "$result") -scorer_version=$(jq -er '.scorer_version' "$result") -tmp_output=$(mktemp "${TMPDIR:-/tmp}/event-scored-state.XXXXXX") -trap 'rm -f -- "$tmp_output"' EXIT -jq --arg attempt "$attempt_id" --arg team "$team_id" --arg status "$status" --arg payload "$payload_digest" --arg scorer "$scorer_id" --arg version "$scorer_version" --arg commit "$commit_sha" --argjson revision "$new_revision" \ - '.revision = $revision - | .results[$attempt] = {attempt_id:$attempt, team_id:$team, status:$status, payload_sha256:$payload, scorer_id:$scorer, scorer_version:$version, source_commit:$commit} - | .attempts[$attempt].status = "completed"' "$state" > "$tmp_output" -jq -e -cS . "$tmp_output" > "$output" || event_die 'failed to produce canonical scored registry state' -rm -f -- "$tmp_output" -printf '%s\n' "applied isolated scoring result for attempt $attempt_id at registry revision $new_revision" diff --git a/scripts/admin/apply-submission.sh b/scripts/admin/apply-submission.sh deleted file mode 100644 index 3f344a1..0000000 --- a/scripts/admin/apply-submission.sh +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'apply-submission.sh --state FILE --request FILE --actual-author ID --pr NUMBER --out FILE [--bundle FILE] [--metadata FILE --source-time RFC3339 --eventctl PATH --config FILE --authority FILE --state-meta FILE --identity-registry FILE] [--plan-only]' -} - -state='' -request='' -actual_author='' -pr_number='' -output='' -bundle='' -metadata='' -source_time='' -eventctl='' -config='' -authority='' -state_meta='' -identity_registry='' -plan_only=false -while (($# > 0)); do - case "$1" in - --state) - (($# >= 2)) || usage - state=$2 - shift 2 - ;; - --request) - (($# >= 2)) || usage - request=$2 - shift 2 - ;; - --actual-author) - (($# >= 2)) || usage - actual_author=$2 - shift 2 - ;; - --pr) - (($# >= 2)) || usage - pr_number=$2 - shift 2 - ;; - --out) - (($# >= 2)) || usage - output=$2 - shift 2 - ;; - --bundle) - (($# >= 2)) || usage - bundle=$2 - shift 2 - ;; - --metadata) - (($# >= 2)) || usage - metadata=$2 - shift 2 - ;; - --source-time) - (($# >= 2)) || usage - source_time=$2 - shift 2 - ;; - --eventctl) - (($# >= 2)) || usage - eventctl=$2 - shift 2 - ;; - --config) - (($# >= 2)) || usage - config=$2 - shift 2 - ;; - --authority) - (($# >= 2)) || usage - authority=$2 - shift 2 - ;; - --state-meta) - (($# >= 2)) || usage - state_meta=$2 - shift 2 - ;; - --identity-registry) - (($# >= 2)) || usage - identity_registry=$2 - shift 2 - ;; - --plan-only) - plan_only=true - shift - ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$state" && -n "$request" && -n "$actual_author" && -n "$pr_number" && -n "$output" ]] || usage -[[ "$pr_number" =~ ^[1-9][0-9]*$ ]] || event_die 'PR number must be a positive integer' -if [[ "$plan_only" == false ]]; then - [[ -n "$bundle" && -n "$metadata" && -n "$source_time" && -n "$eventctl" && -n "$config" && -n "$authority" && -n "$state_meta" && -n "$identity_registry" ]] || \ - event_usage 'normal submission admission requires bundle, metadata, eventctl trust context, and source time; use --plan-only only for local structural tests' -fi -event_require_command jq -event_require_file 'registry state' "$state" -event_require_file 'submission request' "$request" -event_assert_canonical_json "$state" -event_assert_canonical_json "$request" -if [[ -n "$bundle" ]]; then - event_require_file 'submission bundle' "$bundle" -fi -if [[ -n "$metadata" ]]; then - event_require_file 'eventctl PR metadata' "$metadata" - event_assert_canonical_json "$metadata" -fi - -request_pr=$(jq -er '.pr_number' "$request") -request_pr_id=$(jq -er '.pr_id' "$request") -request_fork=$(jq -er '.fork_repository_id' "$request") -request_owner=$(jq -er '.head_owner' "$request") -request_branch=$(jq -er '.head_branch' "$request") -request_sha=$(jq -er '.head_sha' "$request") -if [[ -n "$metadata" ]]; then - actual_pr=$(jq -er '.pull_request.number' "$metadata") - actual_pr_id=$(jq -er '.pull_request.id' "$metadata") - actual_fork=$(jq -er '.pull_request.head_repository_id' "$metadata") - actual_owner=$(jq -er '.pull_request.head_owner' "$metadata") - actual_branch=$(jq -er '.pull_request.head_ref' "$metadata") - actual_sha=$(jq -er '.pull_request.head_sha' "$metadata") -else - actual_pr=$request_pr - actual_pr_id=$request_pr_id - actual_fork=$request_fork - actual_owner=$request_owner - actual_branch=$request_branch - actual_sha=$request_sha -fi -[[ "$actual_pr" == "$pr_number" ]] || event_die 'admin PR number does not match authenticated request metadata' -action_args=(--request "$request" --actual-author "$actual_author" --registry "$state") -if [[ -n "$bundle" ]]; then - action_args+=(--bundle "$bundle") -fi -action_args+=(--actual-pr "$actual_pr" --actual-pr-id "$actual_pr_id" --actual-fork-repository-id "$actual_fork" --actual-head-owner "$actual_owner" --actual-head-branch "$actual_branch" --actual-head-sha "$actual_sha") -bash "$SCRIPT_DIR/../actions/submission.sh" "${action_args[@]}" >/dev/null - -if [[ "$plan_only" == false ]]; then - event_require_file 'eventctl binary' "$eventctl" - [[ -x "$eventctl" ]] || event_die "eventctl binary is not executable: $eventctl" - event_require_file 'signed event config' "$config" - event_require_file 'protected organizer authority' "$authority" - event_require_file 'protected state metadata' "$state_meta" - event_require_file 'eventctl identity registry' "$identity_registry" - eventctl_request=$(mktemp "${TMPDIR:-/tmp}/eventctl-submission.XXXXXX") - verified_request=$(mktemp "${TMPDIR:-/tmp}/verified-submission.XXXXXX") - trap 'rm -f -- "$eventctl_request" "$verified_request"' EXIT - jq -e -cS '.eventctl_request' "$request" > "$eventctl_request" - "$eventctl" submission verify-request --config "$config" --authority "$authority" --state-meta "$state_meta" --registry "$identity_registry" --request "$eventctl_request" --metadata "$metadata" --bundle "$bundle" --expect-actor-id "$actual_author" --source-time "$source_time" --out "$verified_request" >/dev/null -fi - -attempt_id=$(jq -er '.attempt_id' "$request") -team_id=$(jq -er '.team_id' "$request") -bundle_digest=$(jq -er '.bundle_sha256' "$request") -existing_attempt=$(jq -c --arg attempt "$attempt_id" '(.attempts[$attempt] // null)' "$state") -if [[ "$existing_attempt" != 'null' ]]; then - jq -e --arg attempt "$attempt_id" --arg team "$team_id" --arg actor "$actual_author" --arg digest "$bundle_digest" \ - '.attempts[$attempt].team_id == $team and .attempts[$attempt].github_id == $actor and .attempts[$attempt].bundle_sha256 == $digest' "$state" >/dev/null || \ - event_die 'attempt_id is already bound to different immutable submission data' - jq -e -cS . "$state" > "$output" - printf '%s\n' "exact replay returned existing attempt $attempt_id without a new registry revision" - exit 0 -fi -phase=$(jq -er '.phase' "$state") -[[ "$phase" == 'submissions_open' || "$phase" == 'frozen' ]] || event_die 'submission is outside the configured lifecycle' -maximum_total=$(jq -er '.quotas.maximum_total_attempts' "$state") -maximum_team=$(jq -er '.quotas.maximum_attempts_per_team' "$state") -reserved_total=$(jq -er '.quotas.reserved_total' "$state") -reserved_team=$(jq -er --arg team "$team_id" '(.quotas.reserved_by_team[$team] // 0)' "$state") -[[ "$reserved_total" -lt "$maximum_total" ]] || event_die 'global submission quota is exhausted' -[[ "$reserved_team" -lt "$maximum_team" ]] || event_die 'team submission quota is exhausted' -revision=$(jq -er '.revision' "$state") -new_revision=$((revision + 1)) -new_reserved_total=$((reserved_total + 1)) -new_reserved_team=$((reserved_team + 1)) -request_digest=$(event_json_digest "$request") -request_fork=$(jq -er '.fork_repository_id' "$request") -request_branch=$(jq -er '.head_branch' "$request") -request_sha=$(jq -er '.head_sha' "$request") -request_pr_id=$(jq -er '.pr_id' "$request") - -tmp_output=$(mktemp "${TMPDIR:-/tmp}/event-submission-state.XXXXXX") -jq --arg attempt "$attempt_id" --arg team "$team_id" --arg actor "$actual_author" --arg digest "$bundle_digest" --arg request_digest "$request_digest" \ - --arg fork "$request_fork" --arg branch "$request_branch" --arg sha "$request_sha" --arg pr_id "$request_pr_id" \ - --argjson pr "$pr_number" --argjson revision "$new_revision" --argjson reserved_total "$new_reserved_total" --argjson reserved_team "$new_reserved_team" \ - ' .revision = $revision - | .quotas.reserved_total = $reserved_total - | .quotas.reserved_by_team[$team] = $reserved_team - | .attempts[$attempt] = { - team_id: $team, - github_id: $actor, - bundle_sha256: $digest, - request_digest: $request_digest, - fork_repository_id: $fork, - head_branch: $branch, - head_sha: $sha, - status: "reserved", - source_pr: $pr, - source_pr_id: $pr_id - }' "$state" > "$tmp_output" -jq -e -cS . "$tmp_output" > "$output" || { - rm -f -- "$tmp_output" - event_die 'failed to produce canonical submission state' -} -rm -f -- "$tmp_output" -if [[ "$plan_only" == true ]]; then - printf '%s\n' "planned structural reservation for attempt $attempt_id at registry revision $new_revision (cryptographic verification not run)" -else - printf '%s\n' "reserved attempt $attempt_id at registry revision $new_revision" -fi diff --git a/scripts/admin/prepare-pr-metadata.sh b/scripts/admin/prepare-pr-metadata.sh deleted file mode 100644 index a68ca84..0000000 --- a/scripts/admin/prepare-pr-metadata.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'prepare-pr-metadata.sh --github-event FILE --out FILE' -} -event='' -output='' -while (($# > 0)); do - case "$1" in - --github-event) (($# >= 2)) || usage; event=$2; shift 2 ;; - --out) (($# >= 2)) || usage; output=$2; shift 2 ;; - -h|--help) usage ;; - *) usage ;; - esac -done -[[ -n "$event" && -n "$output" ]] || usage -event_require_command jq -event_require_file 'GitHub pull-request event' "$event" - -jq -e ' - (.repository.id | numbers and . >= 1) - and (.repository.owner.login | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9-]{0,38}$")) - and (.repository.name | type == "string" and test("^[A-Za-z0-9._-]{1,100}$")) - and (.pull_request.number | numbers and . >= 1) - and (.pull_request.id | numbers and . >= 1) - and (.pull_request.user.id | numbers and . >= 1) - and (.pull_request.base.ref | type == "string" and test("^[A-Za-z0-9._/-]+$")) - and (.pull_request.head.ref | type == "string" and test("^[A-Za-z0-9._/-]+$")) - and (.pull_request.head.sha | type == "string" and test("^[0-9a-f]{40,64}$")) - and (.pull_request.head.repo.id | numbers and . >= 1) - and (.pull_request.head.repo.owner.login | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9-]{0,38}$")) -' "$event" >/dev/null || event_die 'GitHub event does not contain bounded pull-request metadata' - -jq -n -cS --slurpfile event "$event" ' - ($event[0]) as $e - | {kind:"github_pr_metadata", - actor_id:($e.pull_request.user.id | tostring), - pull_request_author_id:($e.pull_request.user.id | tostring), - base_repository:{id:($e.repository.id | tostring), owner:$e.repository.owner.login, name:$e.repository.name}, - pull_request:{ - number:$e.pull_request.number, - id:($e.pull_request.id | tostring), - base_repository_id:($e.repository.id | tostring), - base_ref:$e.pull_request.base.ref, - head_repository_id:($e.pull_request.head.repo.id | tostring), - head_owner:$e.pull_request.head.repo.owner.login, - head_ref:$e.pull_request.head.ref, - head_sha:$e.pull_request.head.sha - }}' > "$output" -event_assert_canonical_json "$output" -printf '%s\n' "prepared canonical eventctl PR metadata at $output" diff --git a/scripts/admin/rebuild-views.sh b/scripts/admin/rebuild-views.sh deleted file mode 100644 index b60e583..0000000 --- a/scripts/admin/rebuild-views.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'rebuild-views.sh --state FILE --users-out FILE --teams-out FILE --memberships-out FILE --submissions-out FILE' -} -state='' -users_out='' -teams_out='' -memberships_out='' -submissions_out='' -while (($# > 0)); do - case "$1" in - --state) (($# >= 2)) || usage; state=$2; shift 2 ;; - --users-out) (($# >= 2)) || usage; users_out=$2; shift 2 ;; - --teams-out) (($# >= 2)) || usage; teams_out=$2; shift 2 ;; - --memberships-out) (($# >= 2)) || usage; memberships_out=$2; shift 2 ;; - --submissions-out) (($# >= 2)) || usage; submissions_out=$2; shift 2 ;; - -h|--help) usage ;; - *) usage ;; - esac -done -[[ -n "$state" && -n "$users_out" && -n "$teams_out" && -n "$memberships_out" && -n "$submissions_out" ]] || usage -event_require_command jq -event_require_file 'registry state' "$state" -event_assert_canonical_json "$state" -event_id=$(jq -er '.event_id' "$state") -jq -e --arg event "$event_id" ' - .schema == "pythonhk.registry-state/v2" - and .event_id == $event - and (.users | type == "object") - and (.teams | type == "object") - and (.memberships | type == "object") - and (.attempts | type == "object") -' "$state" >/dev/null || event_die 'registry state is not a valid normalized state' -tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/event-registry-views.XXXXXX") -trap 'rm -rf -- "$tmp_dir"' EXIT -jq -n -cS --arg schema 'pythonhk.registry-users/v2' --slurpfile state "$state" '{schema:$schema,users:$state[0].users}' > "$tmp_dir/users.json" -jq -n -cS --arg schema 'pythonhk.registry-teams/v2' --slurpfile state "$state" '{schema:$schema,teams:$state[0].teams}' > "$tmp_dir/teams.json" -jq -n -cS --arg schema 'pythonhk.registry-memberships/v2' --slurpfile state "$state" '{schema:$schema,memberships:$state[0].memberships}' > "$tmp_dir/memberships.json" -jq -n -cS --arg schema 'pythonhk.registry-submissions/v2' --slurpfile state "$state" '{schema:$schema,attempts:$state[0].attempts}' > "$tmp_dir/submissions.json" -cp "$tmp_dir/users.json" "$users_out" -cp "$tmp_dir/teams.json" "$teams_out" -cp "$tmp_dir/memberships.json" "$memberships_out" -cp "$tmp_dir/submissions.json" "$submissions_out" -printf '%s\n' "rebuilt public registry views at revision $(jq -er '.revision' "$state")" diff --git a/scripts/admin/register-user.sh b/scripts/admin/register-user.sh deleted file mode 100644 index 7ab7a8e..0000000 --- a/scripts/admin/register-user.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'register-user.sh --state FILE --identity-registry FILE --request FILE --actual-author ID --commit SHA --out-state FILE --out-identity-registry FILE [--eventctl PATH --config FILE --authority FILE --state-meta FILE --source-time RFC3339] [--plan-only]' -} - -state='' -identity_registry='' -request='' -actual_author='' -commit_sha='' -out_state='' -out_identity_registry='' -eventctl='' -config='' -authority='' -state_meta='' -source_time='' -plan_only=false -while (($# > 0)); do - case "$1" in - --state) (($# >= 2)) || usage; state=$2; shift 2 ;; - --identity-registry) (($# >= 2)) || usage; identity_registry=$2; shift 2 ;; - --request) (($# >= 2)) || usage; request=$2; shift 2 ;; - --actual-author) (($# >= 2)) || usage; actual_author=$2; shift 2 ;; - --commit) (($# >= 2)) || usage; commit_sha=$2; shift 2 ;; - --out-state) (($# >= 2)) || usage; out_state=$2; shift 2 ;; - --out-identity-registry) (($# >= 2)) || usage; out_identity_registry=$2; shift 2 ;; - --eventctl) (($# >= 2)) || usage; eventctl=$2; shift 2 ;; - --config) (($# >= 2)) || usage; config=$2; shift 2 ;; - --authority) (($# >= 2)) || usage; authority=$2; shift 2 ;; - --state-meta) (($# >= 2)) || usage; state_meta=$2; shift 2 ;; - --source-time) (($# >= 2)) || usage; source_time=$2; shift 2 ;; - --plan-only) plan_only=true; shift ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$state" && -n "$identity_registry" && -n "$request" && -n "$actual_author" && -n "$commit_sha" && -n "$out_state" && -n "$out_identity_registry" ]] || usage -[[ "$commit_sha" =~ ^[0-9a-f]{40}$ ]] || event_die 'registration commit must be a 40-character lowercase SHA' -if [[ "$plan_only" == false ]]; then - [[ -n "$eventctl" && -n "$config" && -n "$authority" && -n "$state_meta" && -n "$source_time" ]] || \ - event_usage 'normal registration requires eventctl trust context; use --plan-only only for a local structural plan' -fi - -event_require_command jq -event_require_file 'registry state' "$state" -event_require_file 'eventctl identity registry' "$identity_registry" -event_require_file 'registration request' "$request" -event_assert_canonical_json "$state" -event_assert_canonical_json "$identity_registry" -event_assert_canonical_json "$request" - -bash "$SCRIPT_DIR/../actions/registration.sh" --request "$request" --actual-author "$actual_author" >/dev/null -phase=$(jq -er '.phase' "$state") -enabled=$(jq -er '.enabled' "$state") -[[ "$phase" == 'registration_open' || "$phase" == 'formation_open' ]] || event_die "registration is not open in phase $phase" -[[ "$enabled" == 'true' ]] || event_die 'event is disabled' - -actor_id=$(jq -er '.actor_id' "$request") -key_id=$(jq -er '.key_id' "$request") -key_epoch=$(jq -er '.key_epoch' "$request") -event_id=$(jq -er '.event_id' "$request") -[[ "$actor_id" == "$actual_author" ]] || event_die 'registration actor does not equal authenticated author' -[[ "$event_id" == "$(jq -er '.event_id' "$state")" ]] || event_die 'registration belongs to a different event' - -if [[ "$plan_only" == false ]]; then - event_require_file 'eventctl binary' "$eventctl" - [[ -x "$eventctl" ]] || event_die "eventctl binary is not executable: $eventctl" - event_require_file 'signed event config' "$config" - event_require_file 'protected organizer authority' "$authority" - event_require_file 'protected state metadata' "$state_meta" - verified=$(mktemp "${TMPDIR:-/tmp}/verified-registration.XXXXXX") - trap 'rm -f -- "$verified"' EXIT - "$eventctl" identity verify \ - --config "$config" --authority "$authority" --state-meta "$state_meta" \ - --request "$request" --expect-actor-id "$actual_author" --source-time "$source_time" --out "$verified" >/dev/null -fi - -revision=$(jq -er '.revision' "$state") -new_revision=$((revision + 1)) -registration_digest=$(event_json_digest "$request") -public_key=$(jq -er '.participant_key.public_key' "$request") -existing_user=$(jq -c --arg id "$actor_id" '(.users[$id] // null)' "$state") -if [[ "$existing_user" != 'null' ]]; then - jq -e --arg id "$actor_id" --arg key "$key_id" --arg digest "$registration_digest" \ - '.users[$id].key_id == $key and .users[$id].proof_digest == $digest' "$state" >/dev/null || \ - event_die 'actor already has a different active registration' - jq -e --arg id "$actor_id" 'any(.identities[]; .actor_id == $id)' "$identity_registry" >/dev/null || event_die 'state and identity registry disagree for exact registration replay' - jq -e -cS . "$state" > "$out_state" - jq -e -cS . "$identity_registry" > "$out_identity_registry" - printf '%s\n' "exact registration replay returned actor $actor_id without a new registry revision" - exit 0 -fi -jq -e --arg id "$actor_id" 'all(.identities[]; .actor_id != $id)' "$identity_registry" >/dev/null || event_die 'actor already exists in the trusted identity registry' -tmp_state=$(mktemp "${TMPDIR:-/tmp}/event-registration-state.XXXXXX") -tmp_registry=$(mktemp "${TMPDIR:-/tmp}/event-registration-identities.XXXXXX") -trap 'rm -f -- "$tmp_state" "$tmp_registry"' EXIT -jq -c --argjson revision "$new_revision" --arg id "$actor_id" --arg key "$key_id" --arg public "$public_key" --arg proof "$registration_digest" --arg commit "$commit_sha" \ - '.revision = $revision | .users[$id] = {github_id:$id, key_id:$key, public_key:$public, proof_digest:$proof, registration_commit:$commit}' "$state" > "$tmp_state" -jq -c --arg id "$actor_id" --arg epoch "$key_epoch" --arg key "$key_id" --arg public "$public_key" \ - '.identities += [{actor_id:$id, key_epoch:$epoch, identity:{algorithm:"Ed25519", key_id:$key, public_key:$public}}] | .identities |= sort_by((.actor_id|tonumber),(.key_epoch|tonumber))' "$identity_registry" > "$tmp_registry" -jq -e -cS . "$tmp_state" > "$out_state" || event_die 'failed to produce canonical registry state' -jq -e -cS . "$tmp_registry" > "$out_identity_registry" || event_die 'failed to produce canonical identity registry' -rm -f -- "$tmp_state" "$tmp_registry" -if [[ "$plan_only" == true ]]; then - printf '%s\n' "planned structural registration for actor $actor_id at registry revision $new_revision (cryptographic verification not run)" -else - printf '%s\n' "registered actor $actor_id at registry revision $new_revision" -fi diff --git a/scripts/admin/set-lifecycle.sh b/scripts/admin/set-lifecycle.sh deleted file mode 100644 index edc0ea1..0000000 --- a/scripts/admin/set-lifecycle.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'set-lifecycle.sh --state FILE --phase PHASE --enabled true|false --reason TEXT --commit SHA --out FILE' -} -state='' -phase='' -enabled='' -reason='' -commit_sha='' -output='' -while (($# > 0)); do - case "$1" in - --state) (($# >= 2)) || usage; state=$2; shift 2 ;; - --phase) (($# >= 2)) || usage; phase=$2; shift 2 ;; - --enabled) (($# >= 2)) || usage; enabled=$2; shift 2 ;; - --reason) (($# >= 2)) || usage; reason=$2; shift 2 ;; - --commit) (($# >= 2)) || usage; commit_sha=$2; shift 2 ;; - --out) (($# >= 2)) || usage; output=$2; shift 2 ;; - -h|--help) usage ;; - *) usage ;; - esac -done -[[ -n "$state" && -n "$phase" && -n "$enabled" && -n "$commit_sha" && -n "$output" ]] || usage -[[ "$enabled" == true || "$enabled" == false ]] || event_die '--enabled must be true or false' -[[ "$commit_sha" =~ ^[0-9a-f]{40}$ ]] || event_die 'lifecycle commit must be a 40-character lowercase SHA' -event_require_command jq -event_require_file 'registry state' "$state" -event_assert_canonical_json "$state" -case "$phase" in - draft|registration_open|formation_open|submissions_open|frozen|closed|archived) ;; - *) event_die "invalid lifecycle phase: $phase" ;; -esac -[[ "$reason" != *$'\n'* && ${#reason} -le 512 ]] || event_die 'lifecycle reason is too long or contains a newline' -if [[ "$enabled" == false && -z "$reason" ]]; then - event_die 'disabling an event requires a bounded reason' -fi -if [[ "$enabled" == true && -n "$reason" ]]; then - event_die 'an enabled event cannot carry a disabled reason' -fi -current=$(jq -er '.phase' "$state") -order='{"draft":0,"registration_open":1,"formation_open":2,"submissions_open":3,"frozen":4,"closed":5,"archived":6}' -current_index=$(jq -er --arg phase "$current" '.[$phase]' <<< "$order") -target_index=$(jq -er --arg phase "$phase" '.[$phase]' <<< "$order") -[[ "$target_index" -ge "$current_index" ]] || event_die "lifecycle cannot move backward from $current to $phase" -if [[ "$phase" == "closed" || "$phase" == "archived" ]]; then - [[ "$enabled" == false ]] || event_die 'closed and archived events must be disabled' -fi -revision=$(jq -er '.revision' "$state") -new_revision=$((revision + 1)) -tmp_output=$(mktemp "${TMPDIR:-/tmp}/event-lifecycle-state.XXXXXX") -trap 'rm -f -- "$tmp_output"' EXIT -jq --arg phase "$phase" --arg reason "$reason" --arg commit "$commit_sha" --argjson enabled "$enabled" --argjson revision "$new_revision" \ - '.phase=$phase | .enabled=$enabled | .disabled_reason=$reason | .lifecycle_commit=$commit | .revision=$revision' "$state" > "$tmp_output" -jq -e -cS . "$tmp_output" > "$output" || event_die 'failed to produce canonical lifecycle state' -rm -f -- "$tmp_output" -printf '%s\n' "moved event lifecycle to $phase (enabled=$enabled) at revision $new_revision" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh deleted file mode 100644 index ae08367..0000000 --- a/scripts/lib/common.sh +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -event_die() { - printf 'error: %s\n' "$*" >&2 - exit 1 -} - -event_usage() { - printf 'usage error: %s\n' "$*" >&2 - exit 2 -} - -event_require_command() { - command -v "$1" >/dev/null 2>&1 || event_die "required command not found: $1" -} - -event_require_file() { - local label=$1 - local path=$2 - [[ -f "$path" ]] || event_die "$label is not a regular file: $path" - [[ ! -L "$path" ]] || event_die "$label must not be a symlink: $path" -} - -event_require_directory() { - local label=$1 - local path=$2 - [[ -d "$path" ]] || event_die "$label is not a directory: $path" - [[ ! -L "$path" ]] || event_die "$label must not be a symlink: $path" -} - -event_sha256_file() { - local path=$1 - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$path" | awk '{print $1}' - else - shasum -a 256 "$path" | awk '{print $1}' - fi -} - -event_sha256_stdin() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum | awk '{print $1}' - else - shasum -a 256 | awk '{print $1}' - fi -} - -# Hash the canonical JSON document bytes without jq's presentation newline. -# eventctl document digests use the same no-newline convention. Keeping this -# helper separate from event_sha256_file prevents a transport newline from -# becoming part of a signed/request binding. -event_json_digest() { - local input=$1 - event_require_file 'JSON input' "$input" - jq -e -cS . "$input" | awk 'NR == 1 {printf "%s", $0}' | event_sha256_stdin -} - -event_canonical_json() { - local input=$1 - local output=$2 - event_require_file 'JSON input' "$input" - jq -e -cS . "$input" > "$output" || event_die "invalid JSON: $input" -} - -event_assert_canonical_json() { - local input=$1 - local temporary - temporary=$(mktemp "${TMPDIR:-/tmp}/event-canonical.XXXXXX") - event_canonical_json "$input" "$temporary" - if ! cmp -s "$input" "$temporary"; then - rm -f -- "$temporary" - event_die "JSON is not canonical compact sorted JSON: $input" - fi - rm -f -- "$temporary" -} - -event_decimal_id() { - [[ "$1" =~ ^[1-9][0-9]{0,19}$ ]] -} - -event_digest() { - [[ "$1" =~ ^[0-9a-f]{64}$ ]] -} - -event_uuid4() { - [[ "$1" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]] -} - -event_read_pr_number() { - local event_path=${1:-${GITHUB_EVENT_PATH:-}} - [[ -n "$event_path" ]] || event_die 'GITHUB_EVENT_PATH is required' - event_require_file 'GitHub event payload' "$event_path" - jq -er '.pull_request.number | numbers and . >= 1' "$event_path" -} - -event_read_pr_author_id() { - local event_path=${1:-${GITHUB_EVENT_PATH:-}} - [[ -n "$event_path" ]] || event_die 'GITHUB_EVENT_PATH is required' - event_require_file 'GitHub event payload' "$event_path" - jq -er '.pull_request.user.id | numbers and . >= 1 | tostring' "$event_path" -} - -event_read_pr_head_sha() { - local event_path=${1:-${GITHUB_EVENT_PATH:-}} - [[ -n "$event_path" ]] || event_die 'GITHUB_EVENT_PATH is required' - event_require_file 'GitHub event payload' "$event_path" - jq -er '.pull_request.head.sha | strings | select(test("^[0-9a-f]{40}$"))' "$event_path" -} - -event_read_pr_base_ref() { - local event_path=${1:-${GITHUB_EVENT_PATH:-}} - [[ -n "$event_path" ]] || event_die 'GITHUB_EVENT_PATH is required' - event_require_file 'GitHub event payload' "$event_path" - jq -er '.pull_request.base.ref | strings | select(test("^[A-Za-z0-9._/-]{1,128}$"))' "$event_path" -} - -event_require_base_main() { - local base_ref - base_ref=$(event_read_pr_base_ref "$1") - [[ "$base_ref" == "main" ]] || event_die "request PR base must be main, got $base_ref" -} - -event_changed_paths() { - local repository=$1 - local pr_number=$2 - local output=$3 - event_require_command gh - gh api --paginate \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/${repository}/pulls/${pr_number}/files?per_page=100" \ - --jq '.[].filename' > "$output" -} - -event_assert_exact_paths() { - local paths_file=$1 - shift - local expected_file - expected_file=$(mktemp "${TMPDIR:-/tmp}/event-paths.XXXXXX") - printf '%s\n' "$@" | LC_ALL=C sort > "$expected_file" - if ! LC_ALL=C sort "$paths_file" | cmp -s - "$expected_file"; then - printf 'changed paths:\n' >&2 - sed 's/^/ /' "$paths_file" >&2 - rm -f -- "$expected_file" - event_die 'PR changed paths are not exactly the allowed request files' - fi - rm -f -- "$expected_file" -} - -event_fetch_pr_blob() { - local repository=$1 - local ref=$2 - local path=$3 - local output=$4 - event_require_command gh - gh api \ - -H 'Accept: application/vnd.github.raw+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/${repository}/contents/${path}?ref=${ref}" > "$output" -} - -event_require_no_secrets_for_pr() { - [[ "${GITHUB_EVENT_NAME:-}" == pull_request_target ]] || event_die 'validator must run only on pull_request_target' - [[ "${GITHUB_REF:-}" == refs/heads/main ]] || event_die 'validator must run from trusted main' -} diff --git a/scripts/participant/bundle-team-signatures.sh b/scripts/participant/bundle-team-signatures.sh deleted file mode 100644 index 8835e25..0000000 --- a/scripts/participant/bundle-team-signatures.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'bundle-team-signatures.sh --proposal FILE --consent-dir DIR --out FILE' -} - -proposal='' -consent_dir='' -output='' -while (($# > 0)); do - case "$1" in - --proposal) - (($# >= 2)) || usage - proposal=$2 - shift 2 - ;; - --consent-dir) - (($# >= 2)) || usage - consent_dir=$2 - shift 2 - ;; - --out) - (($# >= 2)) || usage - output=$2 - shift 2 - ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$proposal" && -n "$consent_dir" && -n "$output" ]] || usage -event_require_command jq -event_require_file 'team proposal' "$proposal" -event_require_directory 'consent directory' "$consent_dir" -event_assert_canonical_json "$proposal" - -proposal_digest=$(event_json_digest "$proposal") -registration_id=$(jq -er '.registration_id' "$proposal") -eventctl_proposal=$(mktemp "${TMPDIR:-/tmp}/eventctl-proposal.XXXXXX") -trap 'rm -f -- "$eventctl_proposal"' EXIT -jq -e -cS '.eventctl_proposal' "$proposal" > "$eventctl_proposal" || event_die 'proposal has no embedded eventctl proposal' -eventctl_proposal_digest=$(event_json_digest "$eventctl_proposal") -member_count=$(jq -er '.members | length' "$proposal") -entries='[]' -for member_id in $(jq -er '.members[].github_id' "$proposal"); do - consent_file="$consent_dir/${member_id}.json" - event_require_file "consent for $member_id" "$consent_file" - event_assert_canonical_json "$consent_file" - jq -e --arg id "$member_id" --arg digest "$eventctl_proposal_digest" \ - '.kind == "team_consent" and .actor_id == $id and .proposal_digest == $digest' \ - "$consent_file" >/dev/null || event_die "consent $member_id does not bind the exact proposal" - key_id=$(jq -er '.key_id' "$consent_file") - proposal_key=$(jq -er --arg id "$member_id" '.members[] | select(.github_id == $id) | .key_id' "$proposal") - [[ "$proposal_key" == "$key_id" ]] || event_die "consent $member_id key does not match the proposal member key" - entries=$(jq -c --arg id "$member_id" --arg key "$key_id" --slurpfile consent "$consent_file" \ - '. + [{github_id:$id, key_id:$key, consent:$consent[0]}]' <<< "$entries") -done - -jq -n -cS --arg schema 'pythonhk.team-signatures/v2' --arg registration "$registration_id" \ - --arg digest "$proposal_digest" --arg algorithm 'Ed25519-multisignature-v1' \ - --argjson signatures "$entries" \ - '{schema:$schema, registration_id:$registration, proposal_sha256:$digest, algorithm:$algorithm, signatures:$signatures}' > "$output" -event_assert_canonical_json "$output" -printf '%s\n' "bundled $member_count explicit member signatures" diff --git a/scripts/participant/prepare-submission-request.sh b/scripts/participant/prepare-submission-request.sh deleted file mode 100644 index e229e2e..0000000 --- a/scripts/participant/prepare-submission-request.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'prepare-submission-request.sh --eventctl-request FILE --out FILE' -} - -eventctl_request='' -output='' -while (($# > 0)); do - case "$1" in - --eventctl-request) (($# >= 2)) || usage; eventctl_request=$2; shift 2 ;; - --out) (($# >= 2)) || usage; output=$2; shift 2 ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$eventctl_request" && -n "$output" ]] || usage -event_require_command jq -event_require_file 'eventctl submission request' "$eventctl_request" -event_assert_canonical_json "$eventctl_request" - -jq -e ' - .kind == "submission_envelope" - and (.event_id | type == "string" and test("^[a-z0-9][a-z0-9._-]{2,63}$")) - and (.attempt_id | type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.actor_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.team_id | type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.base_repository.id | tostring | test("^[1-9][0-9]{0,19}$")) - and (.pull_request.number | numbers and . >= 1) - and (.pull_request.id | tostring | test("^[1-9][0-9]{0,31}$")) - and (.pull_request.head_owner | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9-]{0,38}$")) - and (.pull_request.head_repository_id | tostring | test("^[1-9][0-9]{0,19}$")) - and (.pull_request.head_ref | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")) - and (.pull_request.head_sha | type == "string" and test("^[0-9a-f]{40}$")) - and (.bundle.sha256 | type == "string" and test("^[0-9a-f]{64}$")) -' "$eventctl_request" >/dev/null || event_die 'eventctl submission request is not structurally valid' - -jq -n -cS --slurpfile request "$eventctl_request" ' - ($request[0]) as $r - | {schema:"pythonhk.submission-request/v2", - event_id:$r.event_id, - repository_id:($r.base_repository.id | tostring), - team_id:$r.team_id, - attempt_id:$r.attempt_id, - github_id:$r.actor_id, - pr_number:$r.pull_request.number, - pr_id:$r.pull_request.id, - fork_repository_id:($r.pull_request.head_repository_id | tostring), - head_owner:$r.pull_request.head_owner, - head_branch:$r.pull_request.head_ref, - head_sha:$r.pull_request.head_sha, - bundle_sha256:$r.bundle.sha256, - eventctl_request:$r, - issued_at:$r.issued_at, - expires_at:$r.expires_at}' > "$output" -event_assert_canonical_json "$output" -printf '%s\n' "prepared actor-bound submission request for $(jq -er '.actor_id' "$eventctl_request")" diff --git a/scripts/participant/prepare-team-proof.sh b/scripts/participant/prepare-team-proof.sh deleted file mode 100644 index 8b44f90..0000000 --- a/scripts/participant/prepare-team-proof.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'prepare-team-proof.sh --registration-id UUID --registration FILE --consent FILE --out FILE' -} - -registration_id='' -registration='' -consent='' -output='' -while (($# > 0)); do - case "$1" in - --registration-id) - (($# >= 2)) || usage - registration_id=$2 - shift 2 - ;; - --registration) - (($# >= 2)) || usage - registration=$2 - shift 2 - ;; - --consent) - (($# >= 2)) || usage - consent=$2 - shift 2 - ;; - --out) - (($# >= 2)) || usage - output=$2 - shift 2 - ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$registration_id" && -n "$registration" && -n "$consent" && -n "$output" ]] || usage -event_uuid4 "$registration_id" || event_die 'registration ID must be UUIDv4' -event_require_command jq -event_require_file 'eventctl registration request' "$registration" -event_require_file 'eventctl team consent request' "$consent" -event_assert_canonical_json "$registration" -event_assert_canonical_json "$consent" - -jq -e ' - .kind == "registration_request" - and (.actor_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.key_id | type == "string" and test("^[0-9a-f]{64}$")) - and (.participant_key.key_id == .key_id) - and (.signature.key_id == .key_id) - and (.base_repository.id | tostring | test("^[1-9][0-9]{0,19}$")) -' "$registration" >/dev/null || event_die 'registration request is not a valid eventctl proof-of-possession document' - -jq -e ' - .kind == "team_consent" - and (.actor_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.team_id | type == "string") - and (.proposal_digest | type == "string" and test("^[0-9a-f]{64}$")) - and (.signature.key_id == .key_id) - and (.key_id | type == "string" and test("^[0-9a-f]{64}$")) -' "$consent" >/dev/null || event_die 'team consent is not a valid eventctl binding document' - -registration_actor=$(jq -er '.actor_id' "$registration") -consent_actor=$(jq -er '.actor_id' "$consent") -[[ "$registration_actor" == "$consent_actor" ]] || event_die 'registration and team consent actors differ' -registration_key=$(jq -er '.key_id' "$registration") -consent_key=$(jq -er '.key_id' "$consent") -[[ "$registration_key" == "$consent_key" ]] || event_die 'registration and team consent keys differ' - -jq -n -cS --arg schema 'pythonhk.key-proof/v2' --arg registration_id "$registration_id" \ - --slurpfile registration "$registration" --slurpfile consent "$consent" \ - '{schema:$schema, registration_id:$registration_id, registration:$registration[0], consent:$consent[0]}' > "$output" -event_assert_canonical_json "$output" -printf '%s\n' "prepared actor-bound team proof for $registration_actor" diff --git a/scripts/participant/prepare-team-proposal.sh b/scripts/participant/prepare-team-proposal.sh deleted file mode 100644 index d9e4fc1..0000000 --- a/scripts/participant/prepare-team-proposal.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -# shellcheck source=../lib/common.sh -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'prepare-team-proposal.sh --eventctl-proposal FILE --members FILE --out FILE' -} - -eventctl_proposal='' -members='' -output='' -while (($# > 0)); do - case "$1" in - --eventctl-proposal) - (($# >= 2)) || usage - eventctl_proposal=$2 - shift 2 - ;; - --members) - (($# >= 2)) || usage - members=$2 - shift 2 - ;; - --out) - (($# >= 2)) || usage - output=$2 - shift 2 - ;; - -h|--help) usage ;; - *) usage ;; - esac -done - -[[ -n "$eventctl_proposal" && -n "$members" && -n "$output" ]] || usage -event_require_command jq -event_require_file 'eventctl team proposal' "$eventctl_proposal" -event_require_file 'team member manifest' "$members" -event_assert_canonical_json "$eventctl_proposal" -event_assert_canonical_json "$members" - -jq -e ' - .kind == "team_proposal" - and (.event_id | type == "string") - and (.operation_id | type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.team_id | type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.proposer_actor_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.member_actor_ids | type == "array" and length >= 1 and length <= 64 and all(test("^[1-9][0-9]{0,19}$")) and . == (sort_by(tonumber))) - and (.key_id | type == "string" and test("^[0-9a-f]{64}$")) - and (.issued_at | type == "string") - and (.expires_at | type == "string") -' "$eventctl_proposal" >/dev/null || event_die 'eventctl proposal is not structurally valid' - -jq -e ' - type == "array" and length >= 1 and length <= 64 - and all(.[]; - type == "object" - and (keys_unsorted | sort) == ["github_id", "key_id", "public_key", "role"] - and (.github_id | type == "string" and test("^[1-9][0-9]{0,19}$")) - and (.key_id | type == "string" and test("^[0-9a-f]{64}$")) - and (.public_key | type == "string" and length >= 1 and length <= 256) - and (.role == "captain" or .role == "member") - ) - and ([.[].github_id] | length == (unique | length) and . == (sort_by(tonumber))) - and ([.[].role] | map(select(. == "captain")) | length == 1) -' "$members" >/dev/null || event_die 'team member manifest is not strict sorted member data' - -jq -e --arg proposer "$(jq -er '.proposer_actor_id' "$eventctl_proposal")" --slurpfile member_doc "$members" ' - .member_actor_ids == ($member_doc[0] | map(.github_id)) - and .proposer_actor_id == ($member_doc[0] | map(select(.role == "captain") | .github_id) | .[0]) - and .proposer_actor_id == $proposer - and .key_id == ($member_doc[0] | map(select(.github_id == $proposer) | .key_id) | .[0]) -' "$eventctl_proposal" >/dev/null || event_die 'eventctl proposal and member manifest disagree' - -jq -n -cS --slurpfile proposal "$eventctl_proposal" --slurpfile member_doc "$members" \ - '{schema:"pythonhk.team-proposal/v2", - event_id:$proposal[0].event_id, - repository_id:($proposal[0].base_repository.id | tostring), - registration_id:$proposal[0].operation_id, - team_id:$proposal[0].team_id, - proposer_github_id:$proposal[0].proposer_actor_id, - members:$member_doc[0], - eventctl_proposal:$proposal[0], - issued_at:$proposal[0].issued_at, - expires_at:$proposal[0].expires_at}' > "$output" -event_assert_canonical_json "$output" -printf '%s\n' "prepared repository team manifest for $(jq -er '.team_id' "$eventctl_proposal")" diff --git a/scripts/scoring/validate-result.sh b/scripts/scoring/validate-result.sh deleted file mode 100644 index 3f2fae3..0000000 --- a/scripts/scoring/validate-result.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) -. "$SCRIPT_DIR/../lib/common.sh" - -usage() { - event_usage 'validate-result.sh --state FILE --result FILE --payload FILE' -} -state='' -result='' -payload='' -while (($# > 0)); do - case "$1" in - --state) (($# >= 2)) || usage; state=$2; shift 2 ;; - --result) (($# >= 2)) || usage; result=$2; shift 2 ;; - --payload) (($# >= 2)) || usage; payload=$2; shift 2 ;; - -h|--help) usage ;; - *) usage ;; - esac -done -[[ -n "$state" && -n "$result" && -n "$payload" ]] || usage -event_require_command jq -event_require_file 'registry state' "$state" -event_require_file 'scoring result' "$result" -event_require_file 'scoring payload' "$payload" -event_assert_canonical_json "$state" -event_assert_canonical_json "$result" -payload_digest=$(event_sha256_file "$payload") -jq -e --arg event "$(jq -er '.event_id' "$state")" ' - .schema == "pythonhk.scoring-result/v2" - and (keys_unsorted | sort) == ["attempt_id", "event_id", "issued_at", "payload_sha256", "schema", "scorer_id", "scorer_version", "source_attempt_digest", "status", "team_id"] - and .event_id == $event - and (.attempt_id | test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.team_id | test("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")) - and (.status == "accepted" or .status == "rejected" or .status == "error") - and (.payload_sha256 | test("^[0-9a-f]{64}$")) - and (.scorer_id | test("^[A-Za-z0-9._-]{1,64}$")) - and (.scorer_version | test("^[A-Za-z0-9._+-]{1,64}$")) - and (.source_attempt_digest | test("^[0-9a-f]{64}$")) - and (.issued_at | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$")) -' "$result" >/dev/null || event_die 'scoring result failed strict structural validation' -[[ "$payload_digest" == "$(jq -er '.payload_sha256' "$result")" ]] || event_die 'scoring payload digest does not match result' -attempt_id=$(jq -er '.attempt_id' "$result") -team_id=$(jq -er '.team_id' "$result") -jq -e --arg attempt "$attempt_id" --arg team "$team_id" \ - '.attempts[$attempt].team_id == $team and (.attempts[$attempt].status == "reserved" or .attempts[$attempt].status == "completed")' "$state" >/dev/null || \ - event_die 'scoring result references no reserved attempt for the claimed team' -attempt_digest=$(jq -er --arg attempt "$attempt_id" '.attempts[$attempt].request_digest' "$state") -[[ "$attempt_digest" == "$(jq -er '.source_attempt_digest' "$result")" ]] || event_die 'scoring result is not bound to the exact accepted attempt request' -jq -e --arg attempt "$attempt_id" '(.results[$attempt] // null) == null' "$state" >/dev/null || event_die 'a result already exists for this attempt' -printf '%s\n' 'scoring result structurally valid, payload-bound, attempt-bound, and unseen' diff --git a/settings/rulesets/main.json b/settings/rulesets/main.json index f74b834..fdefa82 100644 --- a/settings/rulesets/main.json +++ b/settings/rulesets/main.json @@ -15,8 +15,7 @@ "required_review_thread_resolution": true }, "required_status_checks": [ - "event / request-shape", - "event / hostile-inputs" + "event / act-e2e" ] }, "bypass_actors": [] diff --git a/tests/e2e/bin/gh b/tests/e2e/bin/gh new file mode 100755 index 0000000..aebcdd4 --- /dev/null +++ b/tests/e2e/bin/gh @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Small deterministic ``gh api`` double used only inside act containers. + +The workflows under test use two read-only GitHub API operations: listing PR +files and fetching a blob at a ref. A local black-box test must not use a +developer token or the live GitHub API, so pytest writes the responses below +``GH_MOCK_ROOT`` and puts this executable first on ``PATH`` in the act job. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from urllib.parse import parse_qs + + +def _error(message: str) -> int: + print(f"fake gh: {message}", file=sys.stderr) + return 2 + + +def _endpoint(arguments: list[str]) -> str | None: + return next((argument for argument in arguments if argument.startswith("repos/")), None) + + +def _jq_expression(arguments: list[str]) -> str | None: + try: + return arguments[arguments.index("--jq") + 1] + except (ValueError, IndexError): + return None + + +def _root() -> Path: + configured = os.environ.get("GH_MOCK_ROOT") + if not configured: + raise RuntimeError("GH_MOCK_ROOT is not set") + return Path(configured) + + +def _files(root: Path, endpoint: str, arguments: list[str]) -> int: + try: + number = endpoint.split("/pulls/", 1)[1].split("/files", 1)[0] + payload = json.loads((root / "pulls" / f"{number}.json").read_text(encoding="utf-8")) + except (IndexError, OSError, json.JSONDecodeError) as error: + return _error(f"cannot load pull-request files: {error}") + + if _jq_expression(arguments) != ".[].filename": + return _error("only --jq '.[].filename' is supported") + for item in payload: + try: + print(item["filename"]) + except (KeyError, TypeError): + return _error("pull-request file fixture has no filename") + return 0 + + +def _content(root: Path, endpoint: str) -> int: + try: + resource, query = endpoint.split("?", 1) + path = resource.split("/contents/", 1)[1] + ref = parse_qs(query)["ref"][0] + mapping = json.loads((root / "contents.json").read_text(encoding="utf-8")) + relative_path = mapping.get(f"{ref}:{path}") + if relative_path is None: + candidates = [ + value + for key, value in mapping.items() + if key.partition(":")[2] == path + ] + if len(candidates) != 1: + raise KeyError(f"no unique content fixture for {ref}:{path}") + relative_path = candidates[0] + content = (root / relative_path).read_bytes() + except (KeyError, IndexError, OSError, ValueError, json.JSONDecodeError) as error: + return _error(f"cannot load content fixture: {error}") + sys.stdout.buffer.write(content) + return 0 + + +def main(arguments: list[str]) -> int: + if not arguments or arguments[0] != "api": + return _error("only 'gh api' is supported") + endpoint = _endpoint(arguments[1:]) + if endpoint is None: + return _error("API endpoint is missing") + try: + root = _root() + except RuntimeError as error: + return _error(str(error)) + + if "/pulls/" in endpoint and "/files" in endpoint: + return _files(root, endpoint, arguments[1:]) + if "/contents/" in endpoint: + return _content(root, endpoint) + return _error(f"unsupported endpoint: {endpoint}") + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..8a19df5 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="session") +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] diff --git a/tests/e2e/test_workflows.py b/tests/e2e/test_workflows.py new file mode 100644 index 0000000..8db184e --- /dev/null +++ b/tests/e2e/test_workflows.py @@ -0,0 +1,1031 @@ +from __future__ import annotations + +import hashlib +import http.server +import json +import os +import platform +import shutil +import subprocess +import tarfile +import threading +from collections.abc import Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit +from urllib.request import urlopen + +import pytest + + +ACT_IMAGE = "catthehacker/ubuntu:act-latest" +ACT_PATH = "/github/workspace/tests/e2e/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +ACT_CONTAINER_ARCH = os.environ.get( + "ACT_CONTAINER_ARCH", + "linux/arm64" if platform.machine() in {"arm64", "aarch64"} else "linux/amd64", +) +CHECKOUT_ACTION_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683" +HEAD_SHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +REPOSITORY = "pythonhk/github-native-event-starter-repo-only" +REPOSITORY_ID = 123456789 +PR_NUMBER = 17 +PR_ID = 1700000000 + + +@dataclass(frozen=True) +class Eventctl: + native: Path + linux: Path + + def run(self, *arguments: str) -> dict[str, Any]: + result = subprocess.run( + [str(self.native), *arguments], + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + response = json.loads(result.stdout) + assert response["ok"] is True, response + return response["result"] + + +@dataclass(frozen=True) +class Participant: + actor_id: str + signing_private: Path + recipient_public: Path + registration: Path + source_time: str + record: dict[str, Any] + + +@dataclass(frozen=True) +class Scenario: + binding: dict[str, Any] + formation_registry: dict[str, Any] + submission_registry: dict[str, Any] + participants: dict[str, Participant] + proposal: Path + consents: dict[str, Path] + submission: Path + bundle: Path + team_id: str + attempt_id: str + + +def _canonical(value: object) -> bytes: + return ( + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + "\n" + ).encode() + + +def _read_json(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text()) + assert isinstance(value, dict) + return value + + +def _write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(_canonical(value)) + + +def _timestamp(value: datetime) -> str: + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + + +def _run_git(checkout: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *arguments], + cwd=checkout, + text=True, + capture_output=True, + check=True, + ) + + +def _workspace_state(checkout: Path) -> tuple[str, str, str]: + return ( + _run_git(checkout, "ls-files", "--stage").stdout, + _run_git(checkout, "status", "--short").stdout, + subprocess.run( + ["git", "diff", "--", "."], + cwd=checkout, + text=True, + capture_output=True, + check=True, + ).stdout, + ) + + +def _assert_no_git_mutation( + checkout: Path, before: tuple[str, str, str] +) -> None: + assert _workspace_state(checkout) == before + + +def _required_binary(name: str) -> Path: + configured = os.environ.get(name) + if not configured: + pytest.fail(f"{name} is required for the eventctl v2 act E2E suite") + path = Path(configured) + if not path.is_file() or not os.access(path, os.X_OK): + pytest.fail(f"{name} is not an executable file: {path}") + return path + + +def _configured_eventctl() -> Eventctl | None: + native = os.environ.get("EVENTCTL_E2E_NATIVE_BIN") + linux = os.environ.get("EVENTCTL_E2E_BIN") + if not native and not linux: + return None + return Eventctl( + native=_required_binary("EVENTCTL_E2E_NATIVE_BIN"), + linux=_required_binary("EVENTCTL_E2E_BIN"), + ) + + +def _asset_key(system: str, machine: str) -> str: + systems = {"Darwin": "darwin", "Linux": "linux"} + machines = { + "x86_64": "amd64", + "AMD64": "amd64", + "amd64": "amd64", + "arm64": "arm64", + "aarch64": "arm64", + } + return f"{systems[system]}-{machines[machine]}" + + +def _download_eventctl(repo_root: Path, destination: Path, asset_key: str) -> Path: + lock = _read_json(repo_root / "tools" / "eventctl.lock.json") + version = lock["version"] + if version == "UNRELEASED": + pytest.fail( + "eventctl is not pinned; set both EVENTCTL_E2E_NATIVE_BIN and EVENTCTL_E2E_BIN" + ) + asset = lock["assets"][asset_key] + archive = destination / asset["name"] + binary = destination / "eventctl" + destination.mkdir() + with urlopen( + f"https://github.com/{lock['repository']}/releases/download/v{version}/{asset['name']}", + timeout=60, + ) as response: + archive.write_bytes(response.read()) + assert hashlib.sha256(archive.read_bytes()).hexdigest() == asset["archive_sha256"] + with tarfile.open(archive, "r:gz") as contents: + member = contents.getmember("eventctl") + assert member.isfile() + source = contents.extractfile(member) + assert source is not None + binary.write_bytes(source.read()) + binary.chmod(binary.stat().st_mode | 0o111) + assert hashlib.sha256(binary.read_bytes()).hexdigest() == asset["binary_sha256"] + return binary + + +@pytest.fixture(scope="session") +def eventctl(repo_root: Path, tmp_path_factory: pytest.TempPathFactory) -> Eventctl: + configured = _configured_eventctl() + if configured: + return configured + root = tmp_path_factory.mktemp("eventctl") + native_key = _asset_key(platform.system(), platform.machine()) + _, _, linux_machine = ACT_CONTAINER_ARCH.partition("/") + linux_key = _asset_key("Linux", linux_machine) + binaries = { + key: _download_eventctl(repo_root, root / key, key) + for key in {native_key, linux_key} + } + return Eventctl(native=binaries[native_key], linux=binaries[linux_key]) + + +def _event(*, author_id: int, created_at: str) -> dict[str, Any]: + return { + "action": "opened", + "repository": { + "id": REPOSITORY_ID, + "full_name": REPOSITORY, + "name": "github-native-event-starter-repo-only", + "owner": {"login": "pythonhk"}, + }, + "pull_request": { + "number": PR_NUMBER, + "id": PR_ID, + "created_at": created_at, + "user": {"id": author_id, "login": "participant"}, + "base": { + "ref": "main", + "sha": "", + "repo": {"id": REPOSITORY_ID, "owner": {"login": "pythonhk"}}, + }, + "head": { + "ref": "participant-request", + "sha": HEAD_SHA, + "repo": { + "id": 987654321, + "owner": {"login": "participant"}, + "full_name": "participant/event", + }, + }, + }, + } + + +def _dispatch_event(*, operation: str, request_path: str, source_times: dict[str, Any]) -> dict[str, Any]: + return { + "ref": "refs/heads/main", + "inputs": { + "operation": operation, + "request_path": request_path, + "source_times": json.dumps(source_times, separators=(",", ":")), + }, + "repository": { + "id": REPOSITORY_ID, + "full_name": REPOSITORY, + "name": "github-native-event-starter-repo-only", + "owner": {"login": "pythonhk"}, + }, + "sender": {"login": "organizer"}, + } + + +def _push_event() -> dict[str, Any]: + return { + "ref": "refs/heads/registry", + "before": "0" * 40, + "after": "", + "repository": { + "id": REPOSITORY_ID, + "full_name": REPOSITORY, + "name": "github-native-event-starter-repo-only", + "owner": {"login": "pythonhk"}, + }, + } + + +def _prepare_api( + checkout: Path, + *, + event: Mapping[str, Any], + changed_paths: list[str], + blobs: Mapping[tuple[str, str], bytes], +) -> Path: + root = checkout / ".act-e2e" + _write_json(root / "event.json", dict(event)) + pull_request = event.get("pull_request") + if isinstance(pull_request, Mapping): + _write_json( + root / "pulls" / f"{pull_request['number']}.json", + [{"filename": path} for path in changed_paths], + ) + + content_map: dict[str, str] = {} + for index, ((ref, path), content) in enumerate(blobs.items()): + relative_path = Path("blobs") / str(index) + target = root / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + content_map[f"{ref}:{path}"] = relative_path.as_posix() + _write_json(root / "contents.json", content_map) + return root / "event.json" + + +def _checkout(repo_root: Path, destination: Path) -> Path: + base_sha = _run_git(repo_root, "rev-parse", "HEAD").stdout.strip() + subprocess.run( + ["git", "clone", "--local", str(repo_root), str(destination)], + text=True, + capture_output=True, + check=True, + ) + _run_git(destination, "checkout", "--detach", base_sha) + _run_git( + destination, + "remote", + "set-url", + "origin", + f"https://github.com/{REPOSITORY}.git", + ) + for relative_path in ( + Path(".github/actions"), + Path(".github/workflows"), + Path("event"), + Path("registry"), + Path("tests/e2e"), + Path("tools"), + ): + source = repo_root / relative_path + if source.exists(): + shutil.copytree(source, destination / relative_path, dirs_exist_ok=True) + return destination + + +def _act( + checkout: Path, + event_path: Path, + workflow: str, + server_url: str, + action_path: Path, + linux_eventctl: Path, + event_name: str = "pull_request_target", +) -> subprocess.CompletedProcess[str]: + artifact_path = checkout / ".act-e2e" / "artifacts" + command = [ + os.environ.get("ACT_BIN", "act"), + "--artifact-server-addr=127.0.0.1", + "--artifact-server-port=0", + f"--artifact-server-path={artifact_path}", + "--no-cache-server", + f"--container-architecture={ACT_CONTAINER_ARCH}", + "--platform", + f"ubuntu-24.04={ACT_IMAGE}", + "--local-repository", + f"{server_url}/actions/checkout@{CHECKOUT_ACTION_SHA}={action_path}", + "--directory", + str(checkout), + "--container-daemon-socket=-", + "--container-options", + ( + "--add-host=host.docker.internal:host-gateway " + f"-v {checkout / 'tests/e2e/bin/gh'}:/usr/local/bin/gh:ro " + f"-v {checkout / '.act-e2e'}:/tmp/gh-mock:ro " + f"-v {linux_eventctl}:/tmp/eventctl:ro" + ), + "--eventpath", + str(event_path), + "--workflows", + workflow, + "--env", + "GH_MOCK_ROOT=/tmp/gh-mock", + "--env", + "EVENTCTL_E2E_BIN=/tmp/eventctl", + "--env", + f"GITHUB_SERVER_URL={server_url}", + "--env", + f"PATH={ACT_PATH}", + "--secret", + "GITHUB_TOKEN=act-e2e-token", + "--rm", + event_name, + ] + return subprocess.run( + command, + cwd=checkout, + text=True, + capture_output=True, + check=False, + timeout=180, + ) + + +@contextmanager +def _git_server(checkout: Path, tmp_path: Path): + fixture_root = checkout / ".act-e2e" + (fixture_root / "pulls").mkdir(parents=True, exist_ok=True) + (fixture_root / "blobs").mkdir(parents=True, exist_ok=True) + if not (fixture_root / "contents.json").exists(): + _write_json(fixture_root / "contents.json", {}) + remote_root = tmp_path / "git-root" + remote = remote_root / "pythonhk" / "github-native-event-starter-repo-only" + remote.parent.mkdir(parents=True) + subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True) + + action_cache = Path.home() / ".cache" / "act" / "actions-checkout.git" + if not action_cache.is_dir(): + raise AssertionError(f"act action cache is missing: {action_cache}") + action_source = tmp_path / "actions-checkout" + subprocess.run( + ["git", "clone", "--local", str(action_cache), str(action_source)], + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "checkout", "--detach", CHECKOUT_ACTION_SHA], + cwd=action_source, + check=True, + capture_output=True, + ) + _run_git(checkout, "config", "user.name", "act-e2e") + _run_git(checkout, "config", "user.email", "act-e2e@example.invalid") + paths = [ + ".github/actions", + ".github/workflows", + "event", + "registry", + "tests/e2e", + "tools", + ".act-e2e/pulls", + ".act-e2e/blobs", + ".act-e2e/contents.json", + ] + if (checkout / "requests").exists(): + paths.append("requests") + _run_git(checkout, "add", *paths) + _run_git(checkout, "commit", "-m", "act e2e fixture") + base_sha = _run_git(checkout, "rev-parse", "HEAD").stdout.strip() + subprocess.run( + ["git", "-C", str(remote), "fetch", str(checkout), base_sha], + check=True, + capture_output=True, + ) + for branch in ("main", "registry"): + subprocess.run( + ["git", "-C", str(remote), "update-ref", f"refs/heads/{branch}", base_sha], + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "-C", str(remote), "symbolic-ref", "HEAD", "refs/heads/main"], + check=True, + capture_output=True, + ) + + server_port = 0 + + class GitHttpHandler(http.server.BaseHTTPRequestHandler): + def _serve_git(self) -> None: + parsed = urlsplit(self.path) + length = int(self.headers.get("Content-Length", "0")) + request_body = self.rfile.read(length) + environment = os.environ.copy() + environment.update( + { + "GIT_PROJECT_ROOT": str(remote_root), + "GIT_HTTP_EXPORT_ALL": "1", + "PATH_INFO": parsed.path, + "QUERY_STRING": parsed.query, + "REQUEST_METHOD": self.command, + "CONTENT_TYPE": self.headers.get("Content-Type", ""), + "CONTENT_LENGTH": str(length), + "REMOTE_ADDR": self.client_address[0], + "SERVER_NAME": "host.docker.internal", + "SERVER_PORT": str(server_port), + "SERVER_PROTOCOL": self.request_version, + } + ) + result = subprocess.run( + ["git", "http-backend"], + input=request_body, + capture_output=True, + check=False, + env=environment, + ) + if result.returncode != 0: + self.send_error(500, result.stderr.decode("utf-8", "replace")) + return + headers, body = result.stdout.split(b"\r\n\r\n", 1) + status = 200 + response_headers: list[tuple[str, str]] = [] + for line in headers.split(b"\r\n"): + name, separator, value = line.partition(b": ") + if not separator: + continue + if name.lower() == b"status": + status = int(value.split(b" ", 1)[0]) + continue + response_headers.append( + (name.decode("ascii"), value.decode("latin-1", "replace")) + ) + self.send_response(status) + for name, value in response_headers: + self.send_header(name, value) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + self._serve_git() + + def do_POST(self) -> None: + self._serve_git() + + def log_message(self, format: str, *args: object) -> None: + del format, args + + server = http.server.ThreadingHTTPServer(("0.0.0.0", 0), GitHttpHandler) + server_port = int(server.server_port) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://host.docker.internal:{server_port}", base_sha, action_source + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _require_act() -> None: + if shutil.which(os.environ.get("ACT_BIN", "act")) is None: + pytest.fail("act is required for the workflow black-box suite") + docker_check = subprocess.run(["docker", "info"], text=True, capture_output=True, check=False) + if docker_check.returncode != 0: + pytest.fail("Docker is required for the workflow black-box suite") + action_cache = Path.home() / ".cache" / "act" / "actions-checkout.git" + if not action_cache.is_dir(): + pytest.fail(f"act checkout action is not cached: {action_cache}") + + +@pytest.fixture +def act_checkout(repo_root: Path, tmp_path: Path) -> Path: + _require_act() + return _checkout(repo_root, tmp_path / "checkout") + + +def _participant( + eventctl: Eventctl, + root: Path, + binding: Path, + passphrase: Path, + actor_id: str, +) -> Participant: + key_directory = root / "keys" / actor_id + generated = eventctl.run( + "key-gen", + "--out", + str(key_directory), + "--passphrase-file", + str(passphrase), + ) + registration = root / "requests" / "users" / f"{actor_id}.json" + registration.parent.mkdir(parents=True, exist_ok=True) + eventctl.run( + "identity", + "register", + "--event", + str(binding), + "--actor-id", + actor_id, + "--sig-private-key", + generated["signing_private_key"], + "--recipient-public-key", + generated["recipient_public_key"], + "--passphrase-file", + str(passphrase), + "--output", + str(registration), + ) + document = _read_json(registration) + record_path = root / "records" / f"{actor_id}.json" + record_path.parent.mkdir(parents=True, exist_ok=True) + eventctl.run( + "identity", + "verify", + "--event", + str(binding), + "--input", + str(registration), + "--expect-actor-id", + actor_id, + "--source-time", + document["issued_at"], + "--output", + str(record_path), + ) + return Participant( + actor_id=actor_id, + signing_private=Path(generated["signing_private_key"]), + recipient_public=Path(generated["recipient_public_key"]), + registration=registration, + source_time=document["issued_at"], + record=_read_json(record_path), + ) + + +@pytest.fixture +def scenario(eventctl: Eventctl, tmp_path: Path) -> Scenario: + root = tmp_path / "scenario" + passphrase = root / "passphrase.txt" + passphrase.parent.mkdir(parents=True) + passphrase.write_text("test passphrase\n") + now = datetime.now(timezone.utc).replace(microsecond=0) + binding = { + "v": 2, + "kind": "event-binding", + "protocol": "eventctl/v2", + "event_id": "act-event-2026", + "event_epoch": 1, + "repository_id": str(REPOSITORY_ID), + "valid_from": _timestamp(now - timedelta(minutes=1)), + "valid_until": _timestamp(now + timedelta(days=8)), + "terms_sha256": "a" * 64, + "ttl_seconds": {"registration": 604800, "team": 604800, "submission": 86400}, + "limits": {"team_min": 1, "team_max": 5, "attempts_per_team": 10, "attempts_total": 200}, + } + binding_path = root / "event" / "binding.json" + _write_json(binding_path, binding) + reference = eventctl.run("doctor", "--event", str(binding_path))["event"] + participants = { + actor_id: _participant(eventctl, root, binding_path, passphrase, actor_id) + for actor_id in ("101", "102") + } + formation_registry = { + "v": 2, + "kind": "event-registry", + "event": reference, + "revision": 2, + "phase": "formation_open", + "enabled": True, + "disabled_reason": "", + "identities": [participants[actor_id].record for actor_id in ("101", "102")], + "teams": [], + "attempts": [], + } + formation_registry_path = root / "registry" / "formation.json" + _write_json(formation_registry_path, formation_registry) + proposal = root / "requests" / "teams" / "22222222-2222-4222-8222-222222222222" / "proposal.json" + proposal.parent.mkdir(parents=True, exist_ok=True) + eventctl.run( + "team", + "propose", + "--event", + str(binding_path), + "--registry", + str(formation_registry_path), + "--team-id", + "22222222-2222-4222-8222-222222222222", + "--actor-id", + "101", + "--member", + "101", + "--member", + "102", + "--sig-private-key", + str(participants["101"].signing_private), + "--passphrase-file", + str(passphrase), + "--output", + str(proposal), + ) + consents: dict[str, Path] = {} + for actor_id, participant in participants.items(): + consent = proposal.parent / "proofs" / f"{actor_id}.json" + consent.parent.mkdir(parents=True, exist_ok=True) + eventctl.run( + "team", + "consent", + "--event", + str(binding_path), + "--registry", + str(formation_registry_path), + "--proposal", + str(proposal), + "--actor-id", + actor_id, + "--sig-private-key", + str(participant.signing_private), + "--passphrase-file", + str(passphrase), + "--output", + str(consent), + ) + consents[actor_id] = consent + verification = root / "records" / "team.json" + team_arguments = [ + "team", + "verify", + "--event", + str(binding_path), + "--registry", + str(formation_registry_path), + "--proposal", + str(proposal), + "--proposal-source-time", + _read_json(proposal)["issued_at"], + ] + for actor_id, consent in consents.items(): + team_arguments.extend( + [ + "--consent", + str(consent), + "--consent-source-time", + f"{actor_id}={_read_json(consent)['issued_at']}", + ] + ) + team_arguments.extend(["--output", str(verification)]) + eventctl.run(*team_arguments) + verified_team = _read_json(verification) + submission_registry = { + **formation_registry, + "revision": 3, + "phase": "submissions_open", + "teams": [ + { + "team_id": verified_team["team_id"], + "proposal_sha256": verified_team["proposal_sha256"], + "members": verified_team["members"], + } + ], + } + payload = root / "bundle" + payload.write_bytes(b"opaque package payload\n") + submission = root / "requests" / "submissions" / "33333333-3333-4333-8333-333333333333" / "request.json" + submission.parent.mkdir(parents=True, exist_ok=True) + eventctl.run( + "submission", + "prepare", + "--event", + str(binding_path), + "--input", + str(payload), + "--team-id", + verified_team["team_id"], + "--attempt-id", + "33333333-3333-4333-8333-333333333333", + "--actor-id", + "101", + "--sig-private-key", + str(participants["101"].signing_private), + "--passphrase-file", + str(passphrase), + "--output", + str(submission), + ) + return Scenario( + binding=binding, + formation_registry=formation_registry, + submission_registry=submission_registry, + participants=participants, + proposal=proposal, + consents=consents, + submission=submission, + bundle=payload, + team_id=verified_team["team_id"], + attempt_id="33333333-3333-4333-8333-333333333333", + ) + + +def _configure_checkout(checkout: Path, scenario: Scenario, registry: dict[str, Any]) -> None: + _write_json(checkout / "event" / "binding.json", scenario.binding) + _write_json(checkout / "registry" / "state.json", registry) + + +def _exercise( + checkout: Path, + event: dict[str, Any], + event_path: Path, + workflow: str, + eventctl: Eventctl, + event_name: str = "pull_request_target", +) -> subprocess.CompletedProcess[str]: + with _git_server(checkout, checkout.parent) as (server_url, base_sha, action_path): + if "pull_request" in event: + event["pull_request"]["base"]["sha"] = base_sha + elif event_name == "push": + event["after"] = base_sha + _write_json(event_path, event) + before = _workspace_state(checkout) + result = _act( + checkout, + event_path, + workflow, + server_url, + action_path, + eventctl.linux, + event_name, + ) + _assert_no_git_mutation(checkout, before) + return result + + +def test_registration_workflow_verifies_a_real_eventctl_request( + act_checkout: Path, eventctl: Eventctl, scenario: Scenario +) -> None: + _configure_checkout(act_checkout, scenario, scenario.formation_registry) + participant = scenario.participants["101"] + path = "requests/users/101.json" + event = _event(author_id=101, created_at=participant.source_time) + event_path = _prepare_api( + act_checkout, + event=event, + changed_paths=[path], + blobs={(HEAD_SHA, path): participant.registration.read_bytes()}, + ) + result = _exercise( + act_checkout, + event, + event_path, + ".github/workflows/registration.yml", + eventctl, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "registration request is signed" in result.stdout + + +def test_registration_workflow_rejects_a_transferred_identity( + act_checkout: Path, eventctl: Eventctl, scenario: Scenario +) -> None: + _configure_checkout(act_checkout, scenario, scenario.formation_registry) + path = "requests/users/102.json" + event = _event(author_id=102, created_at=scenario.participants["101"].source_time) + event_path = _prepare_api( + act_checkout, + event=event, + changed_paths=[path], + blobs={(HEAD_SHA, path): scenario.participants["101"].registration.read_bytes()}, + ) + result = _exercise( + act_checkout, + event, + event_path, + ".github/workflows/registration.yml", + eventctl, + ) + assert result.returncode != 0 + + +def test_team_proposal_workflow_keeps_the_request_pending( + act_checkout: Path, eventctl: Eventctl, scenario: Scenario +) -> None: + _configure_checkout(act_checkout, scenario, scenario.formation_registry) + path = f"requests/teams/{scenario.team_id}/proposal.json" + event = _event(author_id=101, created_at=_read_json(scenario.proposal)["issued_at"]) + event_path = _prepare_api( + act_checkout, + event=event, + changed_paths=[path], + blobs={(HEAD_SHA, path): scenario.proposal.read_bytes()}, + ) + result = _exercise( + act_checkout, + event, + event_path, + ".github/workflows/team-proposal.yml", + eventctl, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "pending member consents" in result.stdout + + +def test_team_consent_workflow_binds_the_github_actor( + act_checkout: Path, eventctl: Eventctl, scenario: Scenario +) -> None: + _configure_checkout(act_checkout, scenario, scenario.formation_registry) + proposal_path = act_checkout / "requests" / "teams" / scenario.team_id / "proposal.json" + proposal_path.parent.mkdir(parents=True, exist_ok=True) + proposal_path.write_bytes(scenario.proposal.read_bytes()) + path = f"requests/teams/{scenario.team_id}/proofs/102.json" + event = _event(author_id=102, created_at=_read_json(scenario.consents["102"])["issued_at"]) + event_path = _prepare_api( + act_checkout, + event=event, + changed_paths=[path], + blobs={ + ("main", f"requests/teams/{scenario.team_id}/proposal.json"): scenario.proposal.read_bytes(), + (HEAD_SHA, path): scenario.consents["102"].read_bytes(), + }, + ) + result = _exercise( + act_checkout, + event, + event_path, + ".github/workflows/team-proof.yml", + eventctl, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "team consent is actor-bound" in result.stdout + + +def test_team_consent_workflow_rejects_another_members_proof( + act_checkout: Path, eventctl: Eventctl, scenario: Scenario +) -> None: + _configure_checkout(act_checkout, scenario, scenario.formation_registry) + proposal_path = act_checkout / "requests" / "teams" / scenario.team_id / "proposal.json" + proposal_path.parent.mkdir(parents=True, exist_ok=True) + proposal_path.write_bytes(scenario.proposal.read_bytes()) + path = f"requests/teams/{scenario.team_id}/proofs/102.json" + event = _event(author_id=102, created_at=_read_json(scenario.consents["101"])["issued_at"]) + event_path = _prepare_api( + act_checkout, + event=event, + changed_paths=[path], + blobs={ + ("main", f"requests/teams/{scenario.team_id}/proposal.json"): scenario.proposal.read_bytes(), + (HEAD_SHA, path): scenario.consents["101"].read_bytes(), + }, + ) + result = _exercise( + act_checkout, + event, + event_path, + ".github/workflows/team-proof.yml", + eventctl, + ) + assert result.returncode != 0 + + +def test_submission_workflow_verifies_active_membership_and_payload( + act_checkout: Path, eventctl: Eventctl, scenario: Scenario +) -> None: + _configure_checkout(act_checkout, scenario, scenario.submission_registry) + request_path = f"requests/submissions/{scenario.attempt_id}/request.json" + bundle_path = f"requests/submissions/{scenario.attempt_id}/bundle" + event = _event(author_id=101, created_at=_read_json(scenario.submission)["issued_at"]) + event_path = _prepare_api( + act_checkout, + event=event, + changed_paths=[request_path, bundle_path], + blobs={ + (HEAD_SHA, request_path): scenario.submission.read_bytes(), + (HEAD_SHA, bundle_path): scenario.bundle.read_bytes(), + ("registry", "registry/state.json"): _canonical(scenario.submission_registry), + }, + ) + result = _exercise( + act_checkout, + event, + event_path, + ".github/workflows/submission.yml", + eventctl, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "submission request is signed" in result.stdout + + +def test_submission_workflow_rejects_actor_replay( + act_checkout: Path, eventctl: Eventctl, scenario: Scenario +) -> None: + _configure_checkout(act_checkout, scenario, scenario.submission_registry) + request_path = f"requests/submissions/{scenario.attempt_id}/request.json" + bundle_path = f"requests/submissions/{scenario.attempt_id}/bundle" + event = _event(author_id=102, created_at=_read_json(scenario.submission)["issued_at"]) + event_path = _prepare_api( + act_checkout, + event=event, + changed_paths=[request_path, bundle_path], + blobs={ + (HEAD_SHA, request_path): scenario.submission.read_bytes(), + (HEAD_SHA, bundle_path): scenario.bundle.read_bytes(), + ("registry", "registry/state.json"): _canonical(scenario.submission_registry), + }, + ) + result = _exercise( + act_checkout, + event, + event_path, + ".github/workflows/submission.yml", + eventctl, + ) + assert result.returncode != 0 + + +def test_registry_workflow_validates_the_single_authoritative_document( + act_checkout: Path, eventctl: Eventctl, scenario: Scenario +) -> None: + _configure_checkout(act_checkout, scenario, scenario.submission_registry) + event = _push_event() + event_path = _prepare_api(act_checkout, event=event, changed_paths=[], blobs={}) + result = _exercise( + act_checkout, + event, + event_path, + ".github/workflows/registry.yml", + eventctl, + event_name="push", + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "registry / canonical-state" in result.stdout + + +def test_admin_plan_verifies_the_full_team_without_a_state_write( + act_checkout: Path, eventctl: Eventctl, scenario: Scenario +) -> None: + _configure_checkout(act_checkout, scenario, scenario.formation_registry) + proposal_path = act_checkout / "requests" / "teams" / scenario.team_id / "proposal.json" + proposal_path.parent.mkdir(parents=True, exist_ok=True) + proposal_path.write_bytes(scenario.proposal.read_bytes()) + for actor_id, consent in scenario.consents.items(): + target = proposal_path.parent / "proofs" / f"{actor_id}.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(consent.read_bytes()) + source_times = { + "proposal": _read_json(scenario.proposal)["issued_at"], + "consents": { + actor_id: _read_json(consent)["issued_at"] + for actor_id, consent in scenario.consents.items() + }, + } + event = _dispatch_event( + operation="activate-team", + request_path=f"requests/teams/{scenario.team_id}", + source_times=source_times, + ) + event_path = _prepare_api(act_checkout, event=event, changed_paths=[], blobs={}) + result = _exercise( + act_checkout, + event, + event_path, + ".github/workflows/admin-plan.yml", + eventctl, + event_name="workflow_dispatch", + ) + assert result.returncode == 0, result.stdout + result.stderr + assert '"verified":true' in result.stdout + assert "No registry write was performed." in result.stdout diff --git a/tests/fixtures/proofs/101.json b/tests/fixtures/proofs/101.json deleted file mode 100644 index b2257a3..0000000 --- a/tests/fixtures/proofs/101.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "schema": "pythonhk.key-proof/v2", - "registration_id": "11111111-1111-4111-8111-111111111111", - "registration": { - "kind": "registration_request", - "event_id": "demo-event-2026", - "actor_id": "101", - "key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "participant_key": {"key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "public_key": "PUB101"}, - "base_repository": {"id": "123456789"}, - "signature": {"key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - }, - "consent": { - "kind": "team_consent", - "event_id": "demo-event-2026", - "actor_id": "101", - "team_id": "22222222-2222-4222-8222-222222222222", - "key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "proposal_digest": "replace-at-test-time", - "base_repository": {"id": "123456789"}, - "signature": {"key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - } -} diff --git a/tests/fixtures/proofs/102.json b/tests/fixtures/proofs/102.json deleted file mode 100644 index af95aeb..0000000 --- a/tests/fixtures/proofs/102.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "schema": "pythonhk.key-proof/v2", - "registration_id": "11111111-1111-4111-8111-111111111111", - "registration": { - "kind": "registration_request", - "event_id": "demo-event-2026", - "actor_id": "102", - "key_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "participant_key": {"key_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "public_key": "PUB102"}, - "base_repository": {"id": "123456789"}, - "signature": {"key_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} - }, - "consent": { - "kind": "team_consent", - "event_id": "demo-event-2026", - "actor_id": "102", - "team_id": "22222222-2222-4222-8222-222222222222", - "key_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "proposal_digest": "replace-at-test-time", - "base_repository": {"id": "123456789"}, - "signature": {"key_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} - } -} diff --git a/tests/fixtures/proofs/103.json b/tests/fixtures/proofs/103.json deleted file mode 100644 index bf21622..0000000 --- a/tests/fixtures/proofs/103.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "schema": "pythonhk.key-proof/v2", - "registration_id": "11111111-1111-4111-8111-111111111111", - "registration": { - "kind": "registration_request", - "event_id": "demo-event-2026", - "actor_id": "103", - "key_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "participant_key": {"key_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "public_key": "PUB103"}, - "base_repository": {"id": "123456789"}, - "signature": {"key_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"} - }, - "consent": { - "kind": "team_consent", - "event_id": "demo-event-2026", - "actor_id": "103", - "team_id": "22222222-2222-4222-8222-222222222222", - "key_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "proposal_digest": "replace-at-test-time", - "base_repository": {"id": "123456789"}, - "signature": {"key_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"} - } -} diff --git a/tests/fixtures/registry-active.json b/tests/fixtures/registry-active.json deleted file mode 100644 index b46e89a..0000000 --- a/tests/fixtures/registry-active.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "schema": "pythonhk.registry-state/v2", - "event_id": "demo-event-2026", - "phase": "submissions_open", - "enabled": true, - "disabled_reason": "", - "lifecycle_commit": "0123456789012345678901234567890123456789", - "revision": 3, - "users": {"101": {"github_id": "101", "key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "public_key": "PUB101", "proof_digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}}, - "teams": {"22222222-2222-4222-8222-222222222222": {"team_id": "22222222-2222-4222-8222-222222222222", "member_ids": ["101"], "proposal_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "activation_commit": "0123456789012345678901234567890123456789ab"}}, - "memberships": {"101": "22222222-2222-4222-8222-222222222222"}, - "attempts": {}, - "quotas": {"maximum_total_attempts": 200, "maximum_attempts_per_team": 10, "reserved_total": 0, "reserved_by_team": {}}, - "results": {}, - "scoring": {"enabled": false, "isolation": "organizer-controlled", "scorer_id": "", "scorer_version": ""} -} diff --git a/tests/fixtures/signatures.json b/tests/fixtures/signatures.json deleted file mode 100644 index 0fd30a5..0000000 --- a/tests/fixtures/signatures.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schema": "pythonhk.team-signatures/v2", - "registration_id": "11111111-1111-4111-8111-111111111111", - "proposal_sha256": "replace-at-test-time", - "algorithm": "Ed25519-multisignature-v1", - "signatures": [ - {"github_id": "101", "key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "consent": {"kind": "team_consent", "event_id": "demo-event-2026", "actor_id": "101", "base_repository": {"id": "123456789"}, "team_id": "22222222-2222-4222-8222-222222222222", "key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "proposal_digest": "replace-at-test-time", "signature": {"key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}, - {"github_id": "102", "key_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "consent": {"kind": "team_consent", "event_id": "demo-event-2026", "actor_id": "102", "base_repository": {"id": "123456789"}, "team_id": "22222222-2222-4222-8222-222222222222", "key_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "proposal_digest": "replace-at-test-time", "signature": {"key_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}} - ] -} diff --git a/tests/fixtures/submission.json b/tests/fixtures/submission.json deleted file mode 100644 index 29b5272..0000000 --- a/tests/fixtures/submission.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "schema": "pythonhk.submission-request/v2", - "event_id": "demo-event-2026", - "repository_id": "123456789", - "team_id": "22222222-2222-4222-8222-222222222222", - "attempt_id": "33333333-3333-4333-8333-333333333333", - "github_id": "101", - "pr_number": 17, - "pr_id": "1700000000000000000", - "fork_repository_id": "987654321", - "head_owner": "participant", - "head_branch": "main", - "head_sha": "0123456789012345678901234567890123456789", - "bundle_sha256": "replace-at-test-time", - "eventctl_request": { - "kind": "submission_envelope", - "event_id": "demo-event-2026", - "attempt_id": "33333333-3333-4333-8333-333333333333", - "actor_id": "101", - "team_id": "22222222-2222-4222-8222-222222222222", - "team_proposal_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "base_repository": {"id": "123456789"}, - "pull_request": {"number": 17, "id": "1700000000000000000", "head_repository_id": "987654321", "head_owner": "participant", "head_ref": "main", "head_sha": "0123456789012345678901234567890123456789"}, - "bundle": {"sha256": "replace-at-test-time"}, - "key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "signature": {"key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - }, - "issued_at": "2026-08-05T00:00:00Z", - "expires_at": "2026-08-06T00:00:00Z" -} diff --git a/tests/fixtures/team.json b/tests/fixtures/team.json deleted file mode 100644 index c453acb..0000000 --- a/tests/fixtures/team.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "schema": "pythonhk.team-proposal/v2", - "event_id": "demo-event-2026", - "repository_id": "123456789", - "registration_id": "11111111-1111-4111-8111-111111111111", - "team_id": "22222222-2222-4222-8222-222222222222", - "proposer_github_id": "101", - "members": [ - {"github_id": "101", "key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "public_key": "PUB101", "role": "captain"}, - {"github_id": "102", "key_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "public_key": "PUB102", "role": "member"}, - {"github_id": "103", "key_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "public_key": "PUB103", "role": "member"} - ], - "eventctl_proposal": { - "kind": "team_proposal", - "protocol": "pythonhk.github-native-event", - "protocol_version": 1, - "event_id": "demo-event-2026", - "event_epoch": "1", - "operation_id": "11111111-1111-4111-8111-111111111111", - "team_id": "22222222-2222-4222-8222-222222222222", - "proposer_actor_id": "101", - "key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "key_epoch": "1", - "member_actor_ids": ["101", "102", "103"], - "base_repository": {"id": "123456789", "name": "demo-event-2026", "owner": "pythonhk"}, - "config_digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "issued_at": "2026-08-05T00:00:00Z", - "expires_at": "2026-08-12T00:00:00Z", - "signature": {"algorithm": "Ed25519", "key_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "value": "PLACEHOLDER"} - }, - "issued_at": "2026-08-05T00:00:00Z", - "expires_at": "2026-08-12T00:00:00Z" -} diff --git a/tests/run-security.sh b/tests/run-security.sh deleted file mode 100644 index 8c4cf77..0000000 --- a/tests/run-security.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -ROOT=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) -# shellcheck source=../scripts/lib/common.sh -. "$ROOT/scripts/lib/common.sh" - -fail() { - printf 'security-test failure: %s\n' "$*" >&2 - exit 1 -} -pass() { - printf 'ok: %s\n' "$*" -} -expect_fail() { - if "$@" >/dev/null 2>&1; then - fail "expected failure: $*" - fi -} -expect_contains() { - local needle=$1 - local file=$2 - grep -F -- "$needle" "$file" >/dev/null || fail "missing $needle in $file" -} - -event_require_command jq -event_require_command bash -bash -n "$ROOT"/scripts/lib/common.sh "$ROOT"/scripts/actions/*.sh "$ROOT"/scripts/admin/*.sh "$ROOT"/scripts/participant/*.sh "$ROOT"/tools/install-eventctl.sh -bash "$ROOT/tools/verify-eventctl-lock.sh" >/dev/null -for schema in "$ROOT"/protocol/schemas/v2/*.json; do - jq -e . "$schema" >/dev/null -done -pass 'shell syntax, lock manifest, and JSON schema files' - -tmp=$(mktemp -d "${TMPDIR:-/tmp}/pythonhk-event-security.XXXXXX") -cleanup() { - rm -rf -- "$tmp" -} -trap cleanup EXIT - -canonical() { - jq -cS . "$1" > "$2" -} - -canonical "$ROOT/tests/fixtures/team.json" "$tmp/team.json" -eventctl_proposal_file="$tmp/eventctl-proposal.json" -jq -e -cS '.eventctl_proposal' "$tmp/team.json" > "$eventctl_proposal_file" -proposal_digest=$(event_json_digest "$tmp/team.json") -eventctl_digest=$(event_json_digest "$eventctl_proposal_file") - -canonical "$ROOT/tests/fixtures/signatures.json" "$tmp/signatures-template.json" -jq -cS --arg digest "$proposal_digest" --arg eventctl_digest "$eventctl_digest" \ - '.proposal_sha256=$digest | .signatures |= map(.consent.proposal_digest=$eventctl_digest)' \ - "$tmp/signatures-template.json" > "$tmp/signatures-pending.json" -pending_output="$tmp/pending.out" -bash "$ROOT/scripts/actions/team-proposal.sh" --proposal "$tmp/team.json" --signatures "$tmp/signatures-pending.json" > "$pending_output" -expect_contains 'PENDING_KEY_PROOFS' "$pending_output" -pass 'incomplete team signature set remains pending' - -jq -cS --arg digest "$proposal_digest" --arg eventctl_digest "$eventctl_digest" \ - '.proposal_sha256=$digest | .signatures |= (map(.consent.proposal_digest=$eventctl_digest) + [{github_id:"103",key_id:"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",consent:{kind:"team_consent",event_id:"demo-event-2026",actor_id:"103",base_repository:{id:"123456789"},team_id:"22222222-2222-4222-8222-222222222222",key_id:"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",proposal_digest:$eventctl_digest,signature:{key_id:"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}}}])' \ - "$tmp/signatures-template.json" > "$tmp/signatures-complete.json" -ready_output="$tmp/ready.out" -bash "$ROOT/scripts/actions/team-proposal.sh" --proposal "$tmp/team.json" --signatures "$tmp/signatures-complete.json" > "$ready_output" -expect_contains 'READY_TO_ACTIVATE' "$ready_output" -pass 'complete team signature set is activation-ready' - -mkdir "$tmp/proofs" -for id in 101 102 103; do - jq -cS --arg digest "$eventctl_digest" '.consent.proposal_digest=$digest' "$ROOT/tests/fixtures/proofs/$id.json" > "$tmp/proofs/$id.json" -done -bash "$ROOT/scripts/actions/team-proof.sh" --proposal "$tmp/team.json" --proof "$tmp/proofs/101.json" --actual-author 101 >/dev/null -expect_fail bash "$ROOT/scripts/actions/team-proof.sh" --proposal "$tmp/team.json" --proof "$tmp/proofs/101.json" --actual-author 102 -jq -cS '.registration.participant_key.public_key="COPIED-PUBLIC-KEY"' "$tmp/proofs/101.json" > "$tmp/proofs/101-wrong-key.json" -expect_fail bash "$ROOT/scripts/actions/team-proof.sh" --proposal "$tmp/team.json" --proof "$tmp/proofs/101-wrong-key.json" --actual-author 101 -pass 'copied member proof is rejected by authenticated actor binding' - -mkdir "$tmp/consents" -for id in 101 102 103; do - jq -cS '.consent' "$tmp/proofs/$id.json" > "$tmp/consents/$id.json" -done -bash "$ROOT/scripts/participant/bundle-team-signatures.sh" --proposal "$tmp/team.json" --consent-dir "$tmp/consents" --out "$tmp/bundled-signatures.json" >/dev/null -canonical "$tmp/bundled-signatures.json" "$tmp/signatures-complete.json" -bash "$ROOT/scripts/actions/team-proposal.sh" --proposal "$tmp/team.json" --signatures "$tmp/signatures-complete.json" >/dev/null -pass 'participant helpers produce the deterministic signature envelope' - -jq -cS '.registration + {key_epoch:"1", participant_key:{key_id:.registration.key_id,public_key:"PUB101"}, signature:{key_id:.registration.key_id}, base_repository:{id:"123456789"}}' "$ROOT/tests/fixtures/proofs/101.json" > "$tmp/registration.json" -bash "$ROOT/scripts/actions/registration.sh" --request "$tmp/registration.json" --actual-author 101 >/dev/null -expect_fail bash "$ROOT/scripts/actions/registration.sh" --request "$tmp/registration.json" --actual-author 999 -pass 'registration request is actor-bound' - -jq -n '{repository:{id:123456789,owner:{login:"pythonhk"},name:"event"},pull_request:{number:17,id:1700000000000000000,user:{id:101},base:{ref:"main"},head:{ref:"submission",sha:"0123456789012345678901234567890123456789",repo:{id:987654321,owner:{login:"participant"}}}}}' > "$tmp/github-event.json" -bash "$ROOT/scripts/admin/prepare-pr-metadata.sh" --github-event "$tmp/github-event.json" --out "$tmp/pr-metadata.json" >/dev/null -jq -e '.kind == "github_pr_metadata" and .actor_id == "101" and .pull_request.base_repository_id == "123456789" and .pull_request.head_owner == "participant"' "$tmp/pr-metadata.json" >/dev/null -pass 'GitHub pull-request metadata is projected into the eventctl transport shape' - -jq -cS '.event_id="demo-event-2026" | .phase="registration_open" | .enabled=true | .revision=0 | .users={} | .teams={} | .memberships={}' "$ROOT/registry/state.json" > "$tmp/registration-state.json" -canonical "$ROOT/registry/identity-registry.json" "$tmp/identity-registry.json" -bash "$ROOT/scripts/admin/register-user.sh" --state "$tmp/registration-state.json" --identity-registry "$tmp/identity-registry.json" --request "$tmp/registration.json" --actual-author 101 --commit 0123456789012345678901234567890123456789 --out-state "$tmp/registered-state.json" --out-identity-registry "$tmp/registered-identities.json" --plan-only >/dev/null -jq -e '.users["101"].key_id == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' "$tmp/registered-state.json" >/dev/null -jq -e '.identities[0].actor_id == "101"' "$tmp/registered-identities.json" >/dev/null -bash "$ROOT/scripts/admin/register-user.sh" --state "$tmp/registered-state.json" --identity-registry "$tmp/registered-identities.json" --request "$tmp/registration.json" --actual-author 101 --commit 0123456789012345678901234567890123456789 --out-state "$tmp/registered-replay-state.json" --out-identity-registry "$tmp/registered-replay-identities.json" --plan-only >/dev/null -cmp -s "$tmp/registered-state.json" "$tmp/registered-replay-state.json" || fail 'exact registration replay changed state' -pass 'organizer registration plan updates both public registries' - -canonical "$ROOT/tests/fixtures/registry-active.json" "$tmp/submission-state.json" -printf 'encrypted test bundle\n' > "$tmp/bundle.eventctl" -bundle_digest=$(event_sha256_file "$tmp/bundle.eventctl") -jq -cS --arg digest "$bundle_digest" '.bundle_sha256=$digest | .eventctl_request.bundle.sha256=$digest' "$ROOT/tests/fixtures/submission.json" > "$tmp/submission.json" -submission_args=(--request "$tmp/submission.json" --registry "$tmp/submission-state.json" --bundle "$tmp/bundle.eventctl" --actual-author 101 --actual-pr 17 --actual-pr-id 1700000000000000000 --actual-fork-repository-id 987654321 --actual-head-owner participant --actual-head-branch main --actual-head-sha 0123456789012345678901234567890123456789) -bash "$ROOT/scripts/actions/submission.sh" "${submission_args[@]}" >/dev/null -bad_actor_args=(--request "$tmp/submission.json" --registry "$tmp/submission-state.json" --bundle "$tmp/bundle.eventctl" --actual-author 999 --actual-pr 17 --actual-pr-id 1700000000000000000 --actual-fork-repository-id 987654321 --actual-head-owner participant --actual-head-branch main --actual-head-sha 0123456789012345678901234567890123456789) -expect_fail bash "$ROOT/scripts/actions/submission.sh" "${bad_actor_args[@]}" -# Explicitly alter immutable head metadata rather than relying on array substitution. -bad_pr_args=(--request "$tmp/submission.json" --registry "$tmp/submission-state.json" --bundle "$tmp/bundle.eventctl" --actual-author 101 --actual-pr 18 --actual-pr-id 1700000000000000000 --actual-fork-repository-id 987654321 --actual-head-owner participant --actual-head-branch main --actual-head-sha 0123456789012345678901234567890123456789) -expect_fail bash "$ROOT/scripts/actions/submission.sh" "${bad_pr_args[@]}" -jq -cS '.eventctl_request.pull_request.number=999' "$tmp/submission.json" > "$tmp/submission-inner-pr-mismatch.json" -inner_pr_args=(--request "$tmp/submission-inner-pr-mismatch.json" --registry "$tmp/submission-state.json" --bundle "$tmp/bundle.eventctl" --actual-author 101 --actual-pr 17 --actual-pr-id 1700000000000000000 --actual-fork-repository-id 987654321 --actual-head-owner participant --actual-head-branch main --actual-head-sha 0123456789012345678901234567890123456789) -expect_fail bash "$ROOT/scripts/actions/submission.sh" "${inner_pr_args[@]}" -pass 'submission actor and immutable PR bindings reject replay substitution' - -bash "$ROOT/scripts/admin/apply-submission.sh" --state "$tmp/submission-state.json" --request "$tmp/submission.json" --actual-author 101 --pr 17 --bundle "$tmp/bundle.eventctl" --out "$tmp/submission-applied.json" --plan-only >/dev/null -bash "$ROOT/scripts/admin/apply-submission.sh" --state "$tmp/submission-applied.json" --request "$tmp/submission.json" --actual-author 101 --pr 17 --bundle "$tmp/bundle.eventctl" --out "$tmp/submission-replay.json" --plan-only >/dev/null -cmp -s "$tmp/submission-applied.json" "$tmp/submission-replay.json" || fail 'exact submission replay changed state' -printf 'altered replay bundle\n' > "$tmp/altered-bundle.eventctl" -replay_args=(--request "$tmp/submission.json" --registry "$tmp/submission-applied.json" --bundle "$tmp/altered-bundle.eventctl" --actual-author 101 --actual-pr 17 --actual-pr-id 1700000000000000000 --actual-fork-repository-id 987654321 --actual-head-owner participant --actual-head-branch main --actual-head-sha 0123456789012345678901234567890123456789) -expect_fail bash "$ROOT/scripts/actions/submission.sh" "${replay_args[@]}" -pass 'exact submission replay is idempotent' - -jq -cS '.attempt_id="44444444-4444-4444-8444-444444444444" | .eventctl_request.attempt_id="44444444-4444-4444-8444-444444444444"' "$tmp/submission.json" > "$tmp/submission-fresh.json" -bash "$ROOT/scripts/admin/apply-submission.sh" --state "$tmp/submission-applied.json" --request "$tmp/submission-fresh.json" --actual-author 101 --pr 17 --bundle "$tmp/bundle.eventctl" --out "$tmp/submission-fresh-state.json" --plan-only >/dev/null -jq -e '.attempts | length == 2' "$tmp/submission-fresh-state.json" >/dev/null -pass 'same bundle with a fresh attempt ID is admitted separately' - -printf '{"score":42,"grader":"isolated-test"}\n' > "$tmp/score.payload.json" -score_payload_digest=$(event_sha256_file "$tmp/score.payload.json") -score_attempt_digest=$(jq -er '.attempts["33333333-3333-4333-8333-333333333333"].request_digest' "$tmp/submission-applied.json") -jq -n -cS --arg digest "$score_payload_digest" --arg attempt_digest "$score_attempt_digest" '{schema:"pythonhk.scoring-result/v2",event_id:"demo-event-2026",attempt_id:"33333333-3333-4333-8333-333333333333",team_id:"22222222-2222-4222-8222-222222222222",status:"accepted",payload_sha256:$digest,scorer_id:"isolated-test",scorer_version:"1.0.0",source_attempt_digest:$attempt_digest,issued_at:"2026-08-06T00:00:00Z"}' > "$tmp/score.result.json" -jq -cS '.scoring.enabled=true | .scoring.scorer_id="isolated-test" | .scoring.scorer_version="1.0.0"' "$tmp/submission-applied.json" > "$tmp/scoring-state.json" -bash "$ROOT/scripts/scoring/validate-result.sh" --state "$tmp/scoring-state.json" --result "$tmp/score.result.json" --payload "$tmp/score.payload.json" >/dev/null -bash "$ROOT/scripts/admin/apply-score.sh" --state "$tmp/scoring-state.json" --result "$tmp/score.result.json" --payload "$tmp/score.payload.json" --commit 0123456789012345678901234567890123456789 --out "$tmp/scored-state.json" >/dev/null -jq -e '.results["33333333-3333-4333-8333-333333333333"].status == "accepted" and .attempts["33333333-3333-4333-8333-333333333333"].status == "completed"' "$tmp/scored-state.json" >/dev/null -expect_fail bash "$ROOT/scripts/admin/apply-score.sh" --state "$tmp/scored-state.json" --result "$tmp/score.result.json" --payload "$tmp/score.payload.json" --commit 0123456789012345678901234567890123456789 --out "$tmp/score-duplicate.json" -pass 'isolated scoring result is payload-bound and single-application' - -bash "$ROOT/scripts/admin/rebuild-views.sh" --state "$tmp/submission-fresh-state.json" --users-out "$tmp/users-index.json" --teams-out "$tmp/teams-index.json" --memberships-out "$tmp/memberships-index.json" --submissions-out "$tmp/submissions-index.json" >/dev/null -jq -e '.users["101"].github_id == "101"' "$tmp/users-index.json" >/dev/null -jq -e '.attempts | length == 2' "$tmp/submissions-index.json" >/dev/null -pass 'derived public registry views are rebuilt from one authoritative state' - -jq -cS . "$ROOT/registry/state.json" > "$tmp/lifecycle-state.json" -bash "$ROOT/scripts/admin/set-lifecycle.sh" --state "$tmp/lifecycle-state.json" --phase registration_open --enabled true --reason '' --commit 0123456789012345678901234567890123456789 --out "$tmp/lifecycle-open.json" >/dev/null -bash "$ROOT/scripts/admin/set-lifecycle.sh" --state "$tmp/lifecycle-open.json" --phase frozen --enabled false --reason 'organizer shutdown' --commit 0123456789012345678901234567890123456789 --out "$tmp/lifecycle-frozen.json" >/dev/null -jq -e '.phase == "frozen" and .enabled == false and .disabled_reason == "organizer shutdown"' "$tmp/lifecycle-frozen.json" >/dev/null -expect_fail bash "$ROOT/scripts/admin/set-lifecycle.sh" --state "$tmp/lifecycle-frozen.json" --phase formation_open --enabled true --reason '' --commit 0123456789012345678901234567890123456789 --out "$tmp/lifecycle-backward.json" -pass 'lifecycle controls enforce monotonic transitions and event shutdown' - -jq -cS '.event_id="demo-event-2026" | .phase="formation_open" | .enabled=true | .revision=0 | .users={} | .teams={} | .memberships={}' "$ROOT/registry/state.json" > "$tmp/formation-state.json" -bash "$ROOT/scripts/admin/activate-team.sh" --state "$tmp/formation-state.json" --proposal "$tmp/team.json" --signatures "$tmp/signatures-complete.json" --proof-dir "$tmp/proofs" --commit 0123456789012345678901234567890123456789 --out "$tmp/activated-state.json" --plan-only >/dev/null -jq -e '.memberships["101"] == "22222222-2222-4222-8222-222222222222" and .memberships["103"] == "22222222-2222-4222-8222-222222222222" and .revision == 1' "$tmp/activated-state.json" >/dev/null -pass 'atomic structural team activation writes all memberships and one revision' - -expect_fail bash "$ROOT/scripts/admin/activate-team.sh" --state "$tmp/activated-state.json" --proposal "$tmp/team.json" --signatures "$tmp/signatures-complete.json" --proof-dir "$tmp/proofs" --commit 0123456789012345678901234567890123456789 --out "$tmp/rejected-state.json" --plan-only -pass 'second activation loses the current-state race' - -if rg -n 'pull_request_target' "$ROOT/.github/workflows" | grep -v 'team-proposal.yml\|team-proof.yml\|submission.yml\|registration.yml' >/dev/null; then - fail 'unexpected pull_request_target workflow' -fi -if rg -n 'checkout.*head|github\.event\.pull_request\.head\.ref' "$ROOT/.github/workflows" >/dev/null; then - fail 'workflow appears to execute or checkout fork code' -fi -pass 'workflow trust-boundary assertions' -printf '%s\n' 'all repository-only security tests passed' diff --git a/tools/eventctl b/tools/eventctl deleted file mode 100644 index 0e67b95..0000000 --- a/tools/eventctl +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -if [[ -n "${EVENTCTL_BIN:-}" ]]; then - exec "$EVENTCTL_BIN" "$@" -fi - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -platform=$(uname -s | tr '[:upper:]' '[:lower:]') -architecture=$(uname -m) -if [[ "$platform" == darwin && "$architecture" == arm64 && -x "$SCRIPT_DIR/.cache/eventctl_darwin_arm64" ]]; then - exec "$SCRIPT_DIR/.cache/eventctl_darwin_arm64" "$@" -fi -if [[ "$platform" == darwin && "$architecture" == x86_64 && -x "$SCRIPT_DIR/.cache/eventctl_darwin_amd64" ]]; then - exec "$SCRIPT_DIR/.cache/eventctl_darwin_amd64" "$@" -fi -printf '%s\n' 'eventctl is not installed; run the documented verified release installer or set EVENTCTL_BIN' >&2 -exit 127 diff --git a/tools/eventctl.lock.json b/tools/eventctl.lock.json index 6896e40..227d3ee 100644 --- a/tools/eventctl.lock.json +++ b/tools/eventctl.lock.json @@ -1,47 +1,26 @@ { - "schema_version": 1, - "repository": "pythonhk/eventctl", - "version": "1.0.0", - "checksums": { - "name": "SHA256SUMS", - "sha256": "135f107b568fd24da8a5d52cdcb892d8b75f54ccd5a105008d31dc419785e58e" - }, "assets": { - "darwin-arm64": { - "archive": "tar.gz", - "name": "eventctl_1.0.0_darwin_arm64.tar.gz", - "sha256": "57de47ffe812e0e560a32680f4fdcddbd00c8e0fc3f74e7f1581a901b5881a2a", - "binary_sha256": "ab759bb00f10c156a2c8ba246facfc1c78a2e0c37eb311be702cd1691dac9418" - }, "darwin-amd64": { - "archive": "tar.gz", - "name": "eventctl_1.0.0_darwin_amd64.tar.gz", - "sha256": "a48a397a91713b1d8f9e48d283c12a96fcb0f29198ea9cb12cb90664aea2ea82", - "binary_sha256": "9de94e6e587bbb92b7693083a0029f2332f72e7cbd6c3f66b31ef0527c9b5cac" + "archive_sha256": "2699fa0d5fbdfc1eb642438858eb848fc8c89f31be4e44ffebc58f9aa8a84f73", + "binary_sha256": "64ab16319570ccfe023e69abe04d4926ac011756e39a0bb9a2af03ed3b6bd480", + "name": "eventctl_0.2.0_darwin_amd64.tar.gz" + }, + "darwin-arm64": { + "archive_sha256": "1fa60568ce3fa79a608ebfae6997c5f5dd9774b3ba78df43fa6a208d13bad9f8", + "binary_sha256": "92d2c897161cc0ba904de000cc6a908ec24d0ff71ec7703de339160e4e7889e7", + "name": "eventctl_0.2.0_darwin_arm64.tar.gz" }, "linux-amd64": { - "archive": "tar.gz", - "name": "eventctl_1.0.0_linux_amd64.tar.gz", - "sha256": "3fa7d8fc7a69a757744552bc9059baf3c5336bce80778c59ac318106e3dfaf99", - "binary_sha256": "ff64751129656cd8c14d1a2a9789434d9a28760432927d3126ac19471d449d3b" + "archive_sha256": "27a377cbe08e82351d7203aa857a68a04ddbede5dc7715c1d4dfad4801bfae6c", + "binary_sha256": "bde111406a40f7f7afeb2689d7152f9fbc89dfbe747df38d29e1f4d5bf7989c3", + "name": "eventctl_0.2.0_linux_amd64.tar.gz" }, "linux-arm64": { - "archive": "tar.gz", - "name": "eventctl_1.0.0_linux_arm64.tar.gz", - "sha256": "da8eefd0faa8f1aacb07bf2d1effedc617cfd6bed8acaa1324ab29a262eec5ea", - "binary_sha256": "bace9c88f1af782a02d737213c961c5d67adb5ecf3990ef126304658c5fcd3d2" - }, - "windows-amd64": { - "archive": "zip", - "name": "eventctl_1.0.0_windows_amd64.zip", - "sha256": "acb948b755d10a2e5289a63114ab11abe743015aa773b92894978c0c6f9cbe14", - "binary_sha256": "0eb4f15cf058cba9591608393131d1c4ac808f43a6d9c87d9da2348cfd6d07b5" - }, - "windows-arm64": { - "archive": "zip", - "name": "eventctl_1.0.0_windows_arm64.zip", - "sha256": "e38113e64f7c9f33ee020dce6f3c6b141d4c8102a92d6800241617df90a09275", - "binary_sha256": "4ff2b62b55eec96ec6dc267efd8878817ded6fe86913e76f09c9780029b03bd0" + "archive_sha256": "e4fc463a1047df35964588b018d5a1aaa1b940020661fc8b665e3a47fef42587", + "binary_sha256": "2ba109d640026f8bdc7c6310c53429936d07645d8225b3ff8b9f4a7a2215cf00", + "name": "eventctl_0.2.0_linux_arm64.tar.gz" } - } + }, + "repository": "pythonhk/eventctl", + "version": "0.2.0" } diff --git a/tools/install-eventctl.sh b/tools/install-eventctl.sh deleted file mode 100644 index ee6deb4..0000000 --- a/tools/install-eventctl.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -ROOT=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) -LOCK="$ROOT/tools/eventctl.lock.json" -VERSION='' -destination='' -while (($# > 0)); do - case "$1" in - --version) (($# >= 2)) || { printf 'usage: install-eventctl.sh --version v1.0.0 [--destination PATH]\n' >&2; exit 2; }; VERSION=$2; shift 2 ;; - --destination) (($# >= 2)) || { printf 'usage: install-eventctl.sh --version v1.0.0 [--destination PATH]\n' >&2; exit 2; }; destination=$2; shift 2 ;; - -h|--help) printf '%s\n' 'usage: install-eventctl.sh --version v1.0.0 [--destination PATH]'; exit 0 ;; - *) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;; - esac -done -[[ -n "$VERSION" ]] || { printf '%s\n' 'error: --version is required' >&2; exit 2; } -[[ -f "$LOCK" ]] || { printf '%s\n' "error: lock file missing: $LOCK" >&2; exit 1; } -command -v curl >/dev/null 2>&1 || { printf '%s\n' 'error: curl is required' >&2; exit 1; } -command -v jq >/dev/null 2>&1 || { printf '%s\n' 'error: jq is required' >&2; exit 1; } -if command -v sha256sum >/dev/null 2>&1; then - hash_file() { sha256sum "$1" | awk '{print $1}'; } -else - command -v shasum >/dev/null 2>&1 || { printf '%s\n' 'error: sha256sum or shasum is required' >&2; exit 1; } - hash_file() { shasum -a 256 "$1" | awk '{print $1}'; } -fi - -platform=$(uname -s | tr '[:upper:]' '[:lower:]') -architecture=$(uname -m) -case "$platform:$architecture" in - darwin:arm64) asset_key='darwin-arm64'; binary_name='eventctl_darwin_arm64' ;; - darwin:x86_64) asset_key='darwin-amd64'; binary_name='eventctl_darwin_amd64' ;; - linux:x86_64) asset_key='linux-amd64'; binary_name='eventctl_linux_amd64' ;; - linux:aarch64|linux:arm64) asset_key='linux-arm64'; binary_name='eventctl_linux_arm64' ;; - mingw*:x86_64|msys*:x86_64|cygwin*:x86_64) asset_key='windows-amd64'; binary_name='eventctl_windows_amd64.exe' ;; - *) printf 'error: unsupported platform %s/%s\n' "$platform" "$architecture" >&2; exit 1 ;; -esac -locked_version=$(jq -er '.version' "$LOCK") -[[ "$VERSION" == "v$locked_version" || "$VERSION" == "$locked_version" ]] || { printf 'error: requested version %s is not the locked version %s\n' "$VERSION" "$locked_version" >&2; exit 1; } -archive_name=$(jq -er --arg key "$asset_key" '.assets[$key].name' "$LOCK") -archive_format=$(jq -er --arg key "$asset_key" '.assets[$key].archive' "$LOCK") -archive_digest=$(jq -er --arg key "$asset_key" '.assets[$key].sha256' "$LOCK") -binary_digest=$(jq -er --arg key "$asset_key" '.assets[$key].binary_sha256' "$LOCK") -if [[ -z "$destination" ]]; then - destination="$ROOT/tools/.cache/$binary_name" -fi -destination_dir=$(dirname -- "$destination") -mkdir -p -- "$destination_dir" -tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/eventctl-install.XXXXXX") -cleanup() { rm -rf -- "$tmp_dir"; } -trap cleanup EXIT -archive="$tmp_dir/$archive_name" -url="https://github.com/pythonhk/eventctl/releases/download/v$locked_version/$archive_name" -curl --fail --silent --show-error --location --retry 3 "$url" --output "$archive" -actual_archive_digest=$(hash_file "$archive") -[[ "$actual_archive_digest" == "$archive_digest" ]] || { printf 'error: archive checksum mismatch\n' >&2; exit 1; } -case "$archive_format" in - tar.gz) tar -xzf "$archive" -C "$tmp_dir" ;; - zip) command -v unzip >/dev/null 2>&1 || { printf '%s\n' 'error: unzip is required for the locked Windows asset' >&2; exit 1; }; unzip -q "$archive" -d "$tmp_dir" ;; - *) printf 'error: unsupported archive format %s\n' "$archive_format" >&2; exit 1 ;; -esac -binary=$(find "$tmp_dir" -type f \( -name eventctl -o -name eventctl.exe -o -name "$binary_name" \) -print -quit) -[[ -n "$binary" ]] || { printf '%s\n' 'error: release archive did not contain an executable eventctl' >&2; exit 1; } -actual_binary_digest=$(hash_file "$binary") -[[ "$actual_binary_digest" == "$binary_digest" ]] || { printf 'error: binary checksum mismatch\n' >&2; exit 1; } -install -m 0755 "$binary" "$destination" -printf '%s\n' "installed eventctl $locked_version at $destination" diff --git a/tools/verify-eventctl-lock.sh b/tools/verify-eventctl-lock.sh deleted file mode 100644 index 73c1daa..0000000 --- a/tools/verify-eventctl-lock.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail -IFS=$'\n\t' - -SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -lock_file="$SCRIPT_DIR/eventctl.lock.json" -command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required' >&2; exit 1; } -[[ -f "$lock_file" && ! -L "$lock_file" ]] || { printf '%s\n' 'eventctl lock is missing' >&2; exit 1; } -jq -e ' - .schema_version == 1 - and .repository == "pythonhk/eventctl" - and (.version | type == "string" and test("^[0-9]+\\.[0-9]+\\.[0-9]+$")) - and (.checksums.name == "SHA256SUMS") - and (.checksums.sha256 | test("^[0-9a-f]{64}$")) - and ([.assets | keys[]] | sort) == ["darwin-amd64","darwin-arm64","linux-amd64","linux-arm64","windows-amd64","windows-arm64"] - and all(.assets[]; (.name | type == "string" and length > 0) and (.sha256 | test("^[0-9a-f]{64}$")) and (.binary_sha256 | test("^[0-9a-f]{64}$"))) -' "$lock_file" >/dev/null -printf 'eventctl lock is complete for version %s\n' "$(jq -er '.version' "$lock_file")"