diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 97cd70a..9fe9c04 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -86,3 +86,27 @@ Production callers pin the full Relay commit SHA. The moving `v1` alias is a discovery and controlled-update target, not an immutable consumer reference. See the [complete pinned caller](../../examples/workflows/repository-intelligence.yml) for an adoption-ready workflow. + +## Publication review and Pages deployment + +`publication-review.yml` accepts an ordinary artifact containing a complete, +caller-built static publication site. It never checks out the caller repository +or invokes a renderer. Under a static `actions: read` and `contents: read` +ceiling, it validates the `beacon.publication-hub/v1` catalog and complete +checksum inventory, then uploads the exact reviewed bytes under a unique +artifact name. + +`publication-pages.yml` is a separate deployment-only surface. The request must +be a push or manual run on the caller's configured default branch. It invokes +the review workflow with read-only permissions, then a job with scoped +`pages: write` and `id-token: write` downloads only that reviewed artifact, +revalidates its tree digest, checks all remote-proof inputs before Pages receives +the bytes, deploys through `github-pages`, and verifies every file and route. + +Both workflows use non-cancelling concurrency so a nested review cannot collide +with deployment and a newer run cannot interrupt a provider mutation. +Pull-request review and default-branch deployment use separate caller jobs with +static permissions; see the +[Antidote](../../examples/workflows/publication-pages-antidote.md) and +[Reflector](../../examples/workflows/publication-pages-reflector.md) examples. +Production consumers pin the full v1.3.0 Relay commit. Refs #38. diff --git a/.github/workflows/publication-pages.yml b/.github/workflows/publication-pages.yml new file mode 100644 index 0000000..375736e --- /dev/null +++ b/.github/workflows/publication-pages.yml @@ -0,0 +1,288 @@ +--- +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: Reusable publication Pages deployment + +on: + workflow_call: + inputs: + artifact-name: + description: Complete static-site artifact uploaded by a caller-owned build job. + required: true + type: string + expected-base-url: + description: Canonical HTTPS base URL recorded in the publication catalog. + required: true + type: string + expected-source-revision: + description: Full caller source commit recorded in the publication catalog. + required: true + type: string + required-routes: + description: JSON array of publication routes required locally and remotely. + required: false + default: "[]" + type: string + catalog-path: + description: Site-relative Beacon publication catalog path. + required: false + default: "site.json" + type: string + checksum-path: + description: Site-relative complete SHA-256 inventory path. + required: false + default: "SHA256SUMS" + type: string + fallback-base-url: + description: Explicitly authorized fallback; when set it must match the catalog. + required: false + default: "" + type: string + verify-fallback: + description: Verify the explicitly authorized fallback URL and every byte. + required: false + default: false + type: boolean + artifact-retention-days: + description: Retention for review and deployment-evidence artifacts. + required: false + default: 30 + type: number + verification-attempts: + description: Maximum remote attempts per resource, capped by Relay. + required: false + default: 8 + type: number + verification-retry-delay-seconds: + description: Delay between remote verification attempts, capped by Relay. + required: false + default: 5 + type: number + verification-request-timeout-seconds: + description: Per-request timeout in seconds, capped by Relay. + required: false + default: 20 + type: number + verification-max-elapsed-seconds: + description: Application-level remote verification deadline, capped by Relay. + required: false + default: 300 + type: number + outputs: + review-artifact-name: + description: Unique ordinary artifact containing the exact reviewed site bytes. + value: "${{ jobs.review.outputs.review-artifact-name }}" + evidence-artifact-name: + description: Deployment verification evidence artifact name. + value: "${{ jobs.deploy.outputs.evidence-artifact-name }}" + page-url: + description: GitHub Pages deployment URL. + value: "${{ jobs.deploy.outputs.page-url }}" + file-count: + description: Number of files in the reviewed complete publication site. + value: "${{ jobs.review.outputs.file-count }}" + site-tree-sha256: + description: Digest of the exact complete SHA256SUMS bytes. + value: "${{ jobs.deploy.outputs.site-tree-sha256 }}" + total-bytes: + description: Total bytes in the reviewed complete publication site. + value: "${{ jobs.review.outputs.total-bytes }}" + +permissions: + contents: read + +concurrency: + group: "relay-publication-pages-${{ github.repository }}-${{ github.ref }}" + cancel-in-progress: false + +jobs: + authorize: + name: Authorize publication deployment request + permissions: {} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + + steps: + - name: Harden runner + # step-security/harden-runner v2.21.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c + with: + egress-policy: audit + + - name: Require the caller default branch + shell: bash + env: + DEFAULT_BRANCH: "${{ github.event.repository.default_branch }}" + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" != "push" && \ + "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + echo "::error::deployment is authorized only for push or workflow_dispatch" + exit 2 + fi + if [[ "${GITHUB_REF_TYPE}" != "branch" || \ + "${GITHUB_REF_NAME}" != "${DEFAULT_BRANCH}" ]]; then + echo "::error::deployment requires the caller repository default branch" + exit 2 + fi + + review: + name: Review exact publication bytes + needs: authorize + permissions: + actions: read + contents: read + uses: $/.github/workflows/publication-review.yml + with: + artifact-name: "${{ inputs.artifact-name }}" + expected-base-url: "${{ inputs.expected-base-url }}" + expected-source-revision: "${{ inputs.expected-source-revision }}" + required-routes: "${{ inputs.required-routes }}" + catalog-path: "${{ inputs.catalog-path }}" + checksum-path: "${{ inputs.checksum-path }}" + fallback-base-url: "${{ inputs.fallback-base-url }}" + artifact-retention-days: "${{ inputs.artifact-retention-days }}" + + deploy: + name: Deploy and verify exact reviewed publication bytes + needs: + - authorize + - review + permissions: + actions: read + contents: read + id-token: write + pages: write + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: + name: github-pages + url: "${{ steps.deployment.outputs.page_url }}" + outputs: + evidence-artifact-name: "${{ steps.names.outputs.evidence-artifact-name }}" + page-url: "${{ steps.deployment.outputs.page_url }}" + site-tree-sha256: "${{ steps.validate.outputs.site-tree-sha256 }}" + + steps: + - name: Harden runner + # step-security/harden-runner v2.21.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c + with: + egress-policy: audit + + - name: Define deployment evidence name + id: names + shell: bash + env: + CALLER_ARTIFACT_NAME: "${{ inputs.artifact-name }}" + run: | + set -euo pipefail + artifact_key="$(printf '%s' "${CALLER_ARTIFACT_NAME}" | sha256sum | cut -c1-16)" + printf 'evidence-artifact-name=publication-pages-deployment-%s-%s-%s\n' \ + "${GITHUB_SHA}" "${GITHUB_RUN_ATTEMPT}" "${artifact_key}" \ + >> "${GITHUB_OUTPUT}" + + - name: Download exact reviewed static site + # actions/download-artifact v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: "${{ needs.review.outputs.review-artifact-name }}" + path: ".relay/publication-site" + + - name: Revalidate downloaded publication site + id: validate + uses: $/actions/validate-publication-site + with: + site-directory: ".relay/publication-site" + expected-base-url: "${{ inputs.expected-base-url }}" + expected-fallback-base-url: "${{ inputs.fallback-base-url }}" + expected-source-revision: "${{ inputs.expected-source-revision }}" + required-routes: "${{ inputs.required-routes }}" + catalog-path: "${{ inputs.catalog-path }}" + checksum-path: "${{ inputs.checksum-path }}" + evidence-output: ".relay/publication-site-validation.json" + + - name: Confirm reviewed site-tree identity + shell: bash + env: + DEPLOY_TREE_SHA256: "${{ steps.validate.outputs.site-tree-sha256 }}" + REVIEW_TREE_SHA256: "${{ needs.review.outputs.site-tree-sha256 }}" + run: | + set -euo pipefail + if [[ ! "${REVIEW_TREE_SHA256}" =~ ^[0-9a-f]{64}$ || \ + "${DEPLOY_TREE_SHA256}" != "${REVIEW_TREE_SHA256}" ]]; then + echo "::error::deployment bytes do not match the validated review artifact" + exit 2 + fi + + - name: Validate deployment and verification configuration + uses: $/actions/verify-publication-pages + with: + site-directory: ".relay/publication-site" + expected-base-url: "${{ inputs.expected-base-url }}" + expected-source-revision: "${{ inputs.expected-source-revision }}" + required-routes: "${{ inputs.required-routes }}" + catalog-path: "${{ inputs.catalog-path }}" + checksum-path: "${{ inputs.checksum-path }}" + fallback-base-url: "${{ inputs.fallback-base-url }}" + verify-fallback: "${{ inputs.verify-fallback }}" + configuration-only: "true" + attempts: "${{ inputs.verification-attempts }}" + retry-delay-seconds: "${{ inputs.verification-retry-delay-seconds }}" + request-timeout-seconds: "${{ inputs.verification-request-timeout-seconds }}" + max-elapsed-seconds: "${{ inputs.verification-max-elapsed-seconds }}" + workflow-ref: "${{ job.workflow_ref }}" + workflow-sha: "${{ job.workflow_sha }}" + evidence-output: ".relay/publication-pages-configuration.json" + + - name: Configure GitHub Pages + # actions/configure-pages v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d + + - name: Upload deployable Pages artifact + # actions/upload-pages-artifact v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 + with: + path: ".relay/publication-site" + + - name: Deploy GitHub Pages + id: deployment + # actions/deploy-pages v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 + + - name: Verify deployed publication Pages + id: verify + uses: $/actions/verify-publication-pages + with: + site-directory: ".relay/publication-site" + expected-base-url: "${{ inputs.expected-base-url }}" + expected-source-revision: "${{ inputs.expected-source-revision }}" + required-routes: "${{ inputs.required-routes }}" + catalog-path: "${{ inputs.catalog-path }}" + checksum-path: "${{ inputs.checksum-path }}" + fallback-base-url: "${{ inputs.fallback-base-url }}" + verify-fallback: "${{ inputs.verify-fallback }}" + deployment-url: "${{ steps.deployment.outputs.page_url }}" + attempts: "${{ inputs.verification-attempts }}" + retry-delay-seconds: "${{ inputs.verification-retry-delay-seconds }}" + request-timeout-seconds: "${{ inputs.verification-request-timeout-seconds }}" + max-elapsed-seconds: "${{ inputs.verification-max-elapsed-seconds }}" + workflow-ref: "${{ job.workflow_ref }}" + workflow-sha: "${{ job.workflow_sha }}" + evidence-output: ".relay/publication-pages-evidence.json" + + - name: Upload deployment verification evidence + if: "${{ always() }}" + # actions/upload-artifact v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: "${{ steps.names.outputs.evidence-artifact-name }}" + path: | + .relay/publication-site-validation.json + .relay/publication-pages-configuration.json + .relay/publication-pages-evidence.json + if-no-files-found: warn + retention-days: "${{ inputs.artifact-retention-days }}" + compression-level: 9 diff --git a/.github/workflows/publication-review.yml b/.github/workflows/publication-review.yml new file mode 100644 index 0000000..6ea80a1 --- /dev/null +++ b/.github/workflows/publication-review.yml @@ -0,0 +1,194 @@ +--- +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: Reusable publication review + +on: + workflow_call: + inputs: + artifact-name: + description: Complete static-site artifact uploaded by a caller-owned build job. + required: true + type: string + expected-base-url: + description: Canonical HTTPS base URL recorded in the publication catalog. + required: true + type: string + expected-source-revision: + description: Full caller source commit recorded in the publication catalog. + required: true + type: string + required-routes: + description: JSON array of publication routes required in the reviewed site. + required: false + default: "[]" + type: string + catalog-path: + description: Site-relative Beacon publication catalog path. + required: false + default: "site.json" + type: string + checksum-path: + description: Site-relative complete SHA-256 inventory path. + required: false + default: "SHA256SUMS" + type: string + fallback-base-url: + description: Optional pinned fallback; when set it must match the catalog. + required: false + default: "" + type: string + artifact-retention-days: + description: Retention for reviewed bytes and validation evidence. + required: false + default: 30 + type: number + outputs: + review-artifact-name: + description: Unique ordinary artifact containing the exact reviewed site bytes. + value: "${{ jobs.review.outputs.review-artifact-name }}" + evidence-artifact-name: + description: Local validation evidence artifact name. + value: "${{ jobs.review.outputs.evidence-artifact-name }}" + file-count: + description: Number of files in the reviewed complete publication site. + value: "${{ jobs.review.outputs.file-count }}" + site-tree-sha256: + description: Digest of the exact complete SHA256SUMS bytes. + value: "${{ jobs.review.outputs.site-tree-sha256 }}" + total-bytes: + description: Total bytes in the reviewed complete publication site. + value: "${{ jobs.review.outputs.total-bytes }}" + +permissions: + contents: read + +concurrency: + group: "relay-publication-review-${{ github.repository }}-${{ github.ref }}" + cancel-in-progress: false + +jobs: + authorize: + name: Authorize publication review request + permissions: {} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + + steps: + - name: Harden runner + # step-security/harden-runner v2.21.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c + with: + egress-policy: audit + + - name: Validate review identity and bounds + shell: bash + env: + ARTIFACT_NAME: "${{ inputs.artifact-name }}" + EXPECTED_SOURCE_REVISION: "${{ inputs.expected-source-revision }}" + RETENTION_DAYS: "${{ inputs.artifact-retention-days }}" + run: | + set -euo pipefail + + if [[ ! "${ARTIFACT_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then + echo "::error::artifact-name is not a safe Actions artifact identifier" + exit 2 + fi + if [[ "${ARTIFACT_NAME}" =~ ^publication-pages-(review|validation|deployment)- ]]; then + echo "::error::artifact-name uses a Relay-reserved artifact prefix" + exit 2 + fi + if [[ ! "${EXPECTED_SOURCE_REVISION}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::expected-source-revision must be a full lowercase Git SHA" + exit 2 + fi + if [[ "${EXPECTED_SOURCE_REVISION}" != "${GITHUB_SHA}" ]]; then + echo "::error::expected-source-revision must equal the caller workflow SHA" + exit 2 + fi + if [[ ! "${RETENTION_DAYS}" =~ ^[0-9]+$ ]] || \ + (( RETENTION_DAYS < 1 || RETENTION_DAYS > 90 )); then + echo "::error::artifact-retention-days must be between 1 and 90" + exit 2 + fi + + review: + name: Validate and preserve reviewed publication bytes + needs: authorize + permissions: + actions: read + contents: read + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + evidence-artifact-name: "${{ steps.names.outputs.evidence-artifact-name }}" + review-artifact-name: "${{ steps.names.outputs.review-artifact-name }}" + file-count: "${{ steps.validate.outputs.file-count }}" + site-tree-sha256: "${{ steps.validate.outputs.site-tree-sha256 }}" + total-bytes: "${{ steps.validate.outputs.total-bytes }}" + + steps: + - name: Harden runner + # step-security/harden-runner v2.21.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c + with: + egress-policy: audit + + - name: Define bounded artifact names + id: names + shell: bash + env: + CALLER_ARTIFACT_NAME: "${{ inputs.artifact-name }}" + run: | + set -euo pipefail + artifact_key="$(printf '%s' "${CALLER_ARTIFACT_NAME}" | sha256sum | cut -c1-16)" + printf 'review-artifact-name=publication-pages-review-%s-%s-%s\n' \ + "${GITHUB_SHA}" "${GITHUB_RUN_ATTEMPT}" "${artifact_key}" \ + >> "${GITHUB_OUTPUT}" + printf 'evidence-artifact-name=publication-pages-validation-%s-%s-%s\n' \ + "${GITHUB_SHA}" "${GITHUB_RUN_ATTEMPT}" "${artifact_key}" \ + >> "${GITHUB_OUTPUT}" + + - name: Download caller-built static site + # actions/download-artifact v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: "${{ inputs.artifact-name }}" + path: ".relay/publication-site" + + - name: Validate downloaded publication site + id: validate + uses: $/actions/validate-publication-site + with: + site-directory: ".relay/publication-site" + expected-base-url: "${{ inputs.expected-base-url }}" + expected-fallback-base-url: "${{ inputs.fallback-base-url }}" + expected-source-revision: "${{ inputs.expected-source-revision }}" + required-routes: "${{ inputs.required-routes }}" + catalog-path: "${{ inputs.catalog-path }}" + checksum-path: "${{ inputs.checksum-path }}" + evidence-output: ".relay/publication-site-validation.json" + + - name: Upload exact reviewed static site + # actions/upload-artifact v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: "${{ steps.names.outputs.review-artifact-name }}" + path: ".relay/publication-site" + include-hidden-files: true + if-no-files-found: error + retention-days: "${{ inputs.artifact-retention-days }}" + compression-level: 9 + + - name: Upload local validation evidence + if: "${{ always() }}" + # actions/upload-artifact v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: "${{ steps.names.outputs.evidence-artifact-name }}" + path: ".relay/publication-site-validation.json" + if-no-files-found: warn + retention-days: "${{ inputs.artifact-retention-days }}" + compression-level: 9 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ce48590..4bf3b71 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -133,6 +133,100 @@ jobs: puts "Parsed #{paths.length} YAML metadata documents" ' + publication-site-fixture: + name: Build caller-owned publication fixture + needs: validate + permissions: + contents: read + runs-on: ubuntu-24.04 + timeout-minutes: 5 + + steps: + - name: Harden runner + # step-security/harden-runner v2.21.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c + with: + egress-policy: audit + + - name: Checkout Relay fixture source + # actions/checkout v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Stage a complete static publication site + shell: bash + run: | + set -euo pipefail + python3 - <<'PYTHON' + import os + from pathlib import Path + + from tests.test_publication_site_validation import build_site + + _, site = build_site( + Path("."), + "repository-subpath", + revision=os.environ["GITHUB_SHA"], + ) + print(f"Staged caller-owned publication fixture at {site}") + PYTHON + + - name: Upload caller-owned static-site artifact + # actions/upload-artifact v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: relay-publication-site-fixture + path: dist/repository-subpath + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + compression-level: 9 + + publication-review-smoke: + name: Review caller-owned publication fixture + needs: publication-site-fixture + permissions: + actions: read + contents: read + uses: $/.github/workflows/publication-review.yml + with: + artifact-name: relay-publication-site-fixture + expected-base-url: "https://example.github.io/publication/" + expected-source-revision: "${{ github.sha }}" + required-routes: '["", "paper/", "magazine/", "downloads/"]' + artifact-retention-days: 1 + + publication-review-smoke-outputs: + name: Confirm review-only publication outputs + needs: publication-review-smoke + permissions: {} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + + steps: + - name: Harden runner + # step-security/harden-runner v2.21.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c + with: + egress-policy: audit + + - name: Validate review evidence outputs + shell: bash + env: + EVIDENCE_ARTIFACT: "${{ needs.publication-review-smoke.outputs.evidence-artifact-name }}" + FILE_COUNT: "${{ needs.publication-review-smoke.outputs.file-count }}" + REVIEW_ARTIFACT: "${{ needs.publication-review-smoke.outputs.review-artifact-name }}" + SITE_TREE_SHA256: "${{ needs.publication-review-smoke.outputs.site-tree-sha256 }}" + TOTAL_BYTES: "${{ needs.publication-review-smoke.outputs.total-bytes }}" + run: | + set -euo pipefail + [[ "${EVIDENCE_ARTIFACT}" =~ ^publication-pages-validation- ]] + [[ "${REVIEW_ARTIFACT}" =~ ^publication-pages-review- ]] + [[ "${FILE_COUNT}" =~ ^[1-9][0-9]*$ ]] + [[ "${SITE_TREE_SHA256}" =~ ^[0-9a-f]{64}$ ]] + [[ "${TOTAL_BYTES}" =~ ^[1-9][0-9]*$ ]] + intelligence-smoke: name: Exercise reusable workflow without producer reports needs: validate diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bda3d6a..d83e645 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,7 +8,7 @@ status: provisional owners: - egohygiene created: 2026-08-19 -updated: 2026-08-25 +updated: 2026-08-27 governed_by: - architecture-architecture depends_on: @@ -66,10 +66,14 @@ The diagram is conceptual. [SYSTEM.md](SYSTEM.md) remains authoritative for resp actions/ ├── repository-intelligence/ # read-only collector and static renderer ├── normalize-repository-report/ # producer contract adapter -└── publish-report-snapshot/ # guarded default-branch writer +├── publish-report-snapshot/ # guarded default-branch writer +├── validate-publication-site/ # host-neutral local publication proof +└── verify-publication-pages/ # bounded remote byte proof .github/workflows/ ├── repository-intelligence.yml # reusable artifact orchestration +├── publication-review.yml # statically read-only byte review +├── publication-pages.yml # authorized Pages deployment ├── validate.yml # pull-request and default-branch gate └── release.yml # reviewed manifest or manual SemVer publication @@ -119,6 +123,39 @@ The snapshot publisher is isolated as a separate action because it requires `contents: write`; all other v1 action jobs operate with read-only repository permissions. +## Publication deployment boundary + +Publication Pages preserves three owners instead of absorbing them into one +workflow: + +1. The product repository owns source, native Make/Task commands, rendering, + style, routes, and the complete staged static tree. +2. Beacon owns the renderer-neutral `beacon.publication-hub/v1` public catalog, + lifecycle vocabulary, and complete sorted checksum contract. +3. Relay owns only CI review, authorization, deployment of exact reviewed bytes, + and bounded remote verification evidence. + +Neither reusable workflow checks out caller source or builds content. +`publication-review.yml` has a static read-only ceiling: it downloads the +caller's ordinary artifact, validates it, and uploads the exact accepted bytes +under a unique name. `publication-pages.yml` is deployment-only; it invokes the +read-only review surface, then its write-scoped job can download only that +reviewed artifact. It revalidates the tree digest before the GitHub Pages +boundary and again compares every deployed public byte. This keeps Antidote and +Reflector independently buildable when Beacon or Relay is unavailable. + +Canonical and fallback endpoints must be normalized standard-port HTTPS public +DNS names. Redirects are manually bounded to the exact declared route on one of +those endpoints, DNS answers must be globally routable, response sizes are +bounded by reviewed local bytes, and evidence excludes bodies, headers, local +filesystem roots, and unverified workflow identity. The Actions job timeout is +the outer bound around platform DNS; application retries and reads have tighter +caps. + +Relay v1.3.0 implementation references issue #38. Release publication and real +Antidote/Reflector migrations remain separate evidence gates, so the issue is +not closed by the implementation PR. + ## Dependency rules - Sibling domain capabilities integrate through versioned public contracts, not direct access to internals. diff --git a/README.md b/README.md index 249c08f..dfb0781 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ and deployment. | Scanner report normalization | `egohygiene/relay/actions/normalize-repository-report@v1` | | Guarded report snapshot publication | `egohygiene/relay/actions/publish-report-snapshot@v1` | | Opinionated intelligence artifact workflow | `egohygiene/relay/.github/workflows/repository-intelligence.yml@v1` | +| Publication-site contract validation | `egohygiene/relay/actions/validate-publication-site@v1` | +| Deployed publication byte verification | `egohygiene/relay/actions/verify-publication-pages@v1` | +| Read-only publication review | `egohygiene/relay/.github/workflows/publication-review.yml@v1` | +| Publication Pages deployment | `egohygiene/relay/.github/workflows/publication-pages.yml@v1` | These moving aliases advertise the release surface. Production consumers use a reviewed full commit SHA, as shown below. @@ -66,6 +70,43 @@ https://repository.example/intelligence/dashboard/ The action contract does not depend on a custom domain or a specific root-site stack. +## Review and deploy product-owned publication sites + +Relay v1.3 adds a renderer-neutral Pages lifecycle around a complete static site +artifact. The product repository still owns its source, native Make/Task build, +theme, routes, and staged bytes. Beacon defines the optional +`beacon.publication-hub/v1` catalog contract. Relay only downloads the caller's +artifact and separates static authority into two callable workflows. The +read-only surface validates the catalog and complete `SHA256SUMS` and preserves +the exact reviewed bytes. The deployment-only surface invokes that review, +deploys only its output, and proves the public HTTPS bytes. + +```yaml +jobs: + publication_pages: + permissions: + actions: read + contents: read + id-token: write + pages: write + # Relay publication-pages v1.3.0; production callers pin a full commit SHA. + uses: egohygiene/relay/.github/workflows/publication-pages.yml@ + with: + artifact-name: "publication-site-${{ github.sha }}" + expected-base-url: "https://publication.example.org/" + expected-source-revision: "${{ github.sha }}" + required-routes: '["", "paper/", "magazine/", "downloads/"]' +``` + +The caller must build and upload the ordinary `publication-site-*` artifact in +an earlier job. Deployment requests fail closed unless they originate from a +push or manual run on the configured default branch. Pull requests call +`egohygiene/relay/.github/workflows/publication-review.yml@` +with only `actions: read` and `contents: read`; GitHub therefore never grants a +review run latent Pages or OIDC authority. See the +[Antidote](examples/workflows/publication-pages-antidote.md) and +[Reflector](examples/workflows/publication-pages-reflector.md) caller patterns. + For a standalone, reviewable artifact instead of a Pages composition, call the reusable workflow at the same immutable Relay commit: diff --git a/ROADMAP.md b/ROADMAP.md index 79491d1..7fc14a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ status: provisional owners: - egohygiene created: 2026-08-19 -updated: 2026-08-25 +updated: 2026-08-27 governed_by: - architecture-roadmap depends_on: @@ -164,6 +164,43 @@ issues: [] - Current releases predate the proposed roadmap automation. +### Publication Pages lifecycle track + + +#### REL-PAGES-001 — Ship the reviewed publication Pages lifecycle + +**State:** `active` +**Depends on:** `REL-Q01` + +**Outcome:** Product repositories can keep their native Make/Task publication +builds while pinned, statically separated Relay review and deployment workflows +prove and publish the exact caller-built static bytes. + +**Exit criteria:** + +- [x] Local validation enforces the Beacon public catalog, lifecycle honesty, + route/resource linkage, complete checksums, path safety, and bounded input. +- [x] Read-only review and write-scoped Pages deployment are separate reusable + workflow surfaces; deployment consumes only the exact reviewed artifact. +- [x] Canonical and explicitly authorized fallback endpoints use bounded HTTPS + verification with deterministic, versioned evidence. +- [ ] Relay v1.3.0 is published from the accepted implementation commit. +- [ ] Antidote and Reflector pin that release and pass real default-branch + deployment plus rollback checks without losing native build independence. + +**Current evidence:** + +- Relay issue #38 owns the implementation and release/adoption acceptance. +- The v1.3 implementation PR references rather than closes #38; the issue stays + open through immutable release publication and product migrations. +- Antidote and Reflector static-permission caller patterns are documented under + `examples/workflows/`. + ### Repository Intelligence experience track This track turns the validated organization read model into a low-cognitive-load diff --git a/WORKFLOW_CATALOG.md b/WORKFLOW_CATALOG.md index 8cf5d29..d655694 100644 --- a/WORKFLOW_CATALOG.md +++ b/WORKFLOW_CATALOG.md @@ -10,19 +10,21 @@ and failure semantics. | Workflow | Audience | Owner | Purpose | Maximum authority | Timeout | | --- | --- | --- | --- | --- | --- | -| `relay-validation` | Internal | `egohygiene/relay` | Validate packages, contracts, metadata, and the reusable smoke path | `contents: read` | 15 minutes | +| `relay-validation` | Internal | `egohygiene/relay` | Validate packages, contracts, metadata, and the reusable smoke path | `actions: read`, `contents: read` | 15 minutes | | `relay-release` | Internal | `egohygiene/relay` | Publish a verified immutable release and optional major alias | job-scoped `contents: write` | 20 minutes | +| `publication-review` | Reusable | `egohygiene/relay` | Validate and preserve exact caller-built publication bytes | `actions: read`, `contents: read` | 10 minutes | +| `publication-pages` | Reusable | `egohygiene/relay` | Deploy the exact read-only reviewed artifact and remotely prove it | job-scoped `pages: write` and `id-token: write` | 20 minutes | | `repository-intelligence` | Reusable | `egohygiene/relay` | Build, verify, and upload one bounded intelligence artifact | `contents: read` | 15 minutes | There are no staged workflow implementations in this repository at this -snapshot. All three files under `.github/workflows/` are current and cataloged. +snapshot. All five files under `.github/workflows/` are current and cataloged. A future staged candidate must first receive an owner, purpose, explicit contract, and `experimental` catalog state; an uncataloged workflow fails CI. ## Security contract - Workflow defaults grant only `contents: read`. -- Write permission is job-scoped and bound to the release purpose. +- Write permission is job-scoped and bound to release or Pages deployment. - `write-all` and `pull_request_target` are prohibited. - Every remote action or reusable workflow is pinned to a full 40-character commit SHA. The human-readable release remains in an adjacent comment. @@ -42,19 +44,31 @@ Validation and artifact workflows fail closed: they publish no successful result when validation, provenance, output existence, or upload fails. A newer run on the same validation or intelligence ref cancels stale work. +Publication review and Pages runs never cancel in progress. The review workflow +has no Pages or OIDC authority and stores the exact accepted artifact. The +deployment-only workflow is callable only with explicit write permissions, +invokes review through a read-restricted nested call, and is authorized only for +a push or manual run on the configured default branch. It revalidates that +reviewed artifact before Pages receives it and fails closed on configuration, +provider, HTTPS, route, or byte-proof disagreement. + Release runs are serialized and never cancel in progress. A retry may resume a partial provider-side release only when the immutable tag still points to the same validated default-branch commit; contradictory state fails closed. ## Reusable caller contract -`repository-intelligence` is the only reusable workflow in v1. Its inputs, -defaults, output, permission ceiling, timeout, concurrency key, and failure -semantics are recorded in the catalog and checked against its workflow source. +`repository-intelligence`, `publication-review`, and `publication-pages` are the +reusable workflows in v1. Their inputs, defaults, outputs, permission ceilings, +timeouts, concurrency keys, and failure semantics are recorded in the catalog +and checked against their workflow sources. Production callers pin an immutable Relay commit. See the [complete adoption example](examples/workflows/repository-intelligence.yml) and -[example guidance](examples/workflows/README.md). +[example guidance](examples/workflows/README.md). Publication consumers use +separate static-permission review and deployment calls; the Antidote and +Reflector patterns remain migration documentation until v1.3.0 is released and +the product repositories pin it. Refs #38. ## Versioning diff --git a/action-catalog.json b/action-catalog.json index 0392110..382a3f8 100644 --- a/action-catalog.json +++ b/action-catalog.json @@ -41,9 +41,68 @@ "permissions": {"contents": "read"}, "side_effects": ["workspace-write"], "consumer_ref": "egohygiene/relay/actions/repository-intelligence@v1" + }, + { + "id": "validate-publication-site", + "path": "actions/validate-publication-site", + "name": "Validate Publication Site", + "summary": "Validate caller-built publication hub routes, lifecycle claims, files, and complete checksums without rendering or deploying.", + "status": "beta", + "runtime": [ + "bash", + "python3" + ], + "permissions": {}, + "side_effects": [ + "workspace-write" + ], + "consumer_ref": "egohygiene/relay/actions/validate-publication-site@v1" + }, + { + "id": "verify-publication-pages", + "path": "actions/verify-publication-pages", + "name": "Verify Publication Pages", + "summary": "Verify deployed HTTPS publication routes and every checksummed byte with bounded retries and deterministic evidence.", + "status": "beta", + "runtime": [ + "bash", + "python3" + ], + "permissions": {}, + "side_effects": [ + "network-read", + "workspace-write" + ], + "consumer_ref": "egohygiene/relay/actions/verify-publication-pages@v1" } ], "workflows": [ + { + "id": "publication-review", + "path": ".github/workflows/publication-review.yml", + "name": "Publication Site Review", + "summary": "Validate and preserve exact caller-built publication bytes under a statically read-only permission ceiling.", + "status": "beta", + "permissions": { + "actions": "read", + "contents": "read" + }, + "consumer_ref": "egohygiene/relay/.github/workflows/publication-review.yml@v1" + }, + { + "id": "publication-pages", + "path": ".github/workflows/publication-pages.yml", + "name": "Publication Pages Deployment", + "summary": "Authorize default-branch deployment, consume the exact read-only reviewed tree, and verify public HTTPS routes.", + "status": "beta", + "permissions": { + "actions": "read", + "contents": "read", + "id-token": "write", + "pages": "write" + }, + "consumer_ref": "egohygiene/relay/.github/workflows/publication-pages.yml@v1" + }, { "id": "repository-intelligence", "path": ".github/workflows/repository-intelligence.yml", diff --git a/actions/README.md b/actions/README.md index 3ea0133..efc0f39 100644 --- a/actions/README.md +++ b/actions/README.md @@ -9,6 +9,8 @@ documentation, implementation, contracts, and tests. | [`repository-intelligence`](repository-intelligence/) | Build operational, roadmap, decision, and journey Repository Intelligence views | Workspace files only | | [`normalize-repository-report`](normalize-repository-report/) | Normalize OSV, MegaLinter, and Scorecard producer summaries | Workspace files only | | [`publish-report-snapshot`](publish-report-snapshot/) | Guard and publish stable `.reports` snapshots | Git commit and default-branch push | +| [`validate-publication-site`](validate-publication-site/) | Validate product-owned publication hub bytes and checksums | Workspace evidence only | +| [`verify-publication-pages`](verify-publication-pages/) | Prove deployed HTTPS publication bytes and routes | Network reads and workspace evidence | ## Consumption @@ -20,6 +22,8 @@ paths are: egohygiene/relay/actions/repository-intelligence egohygiene/relay/actions/normalize-repository-report egohygiene/relay/actions/publish-report-snapshot +egohygiene/relay/actions/validate-publication-site +egohygiene/relay/actions/verify-publication-pages ``` GitHub Marketplace is a discovery surface, not the distribution mechanism for diff --git a/actions/validate-publication-site/README.md b/actions/validate-publication-site/README.md new file mode 100644 index 0000000..a8706a2 --- /dev/null +++ b/actions/validate-publication-site/README.md @@ -0,0 +1,58 @@ +# Validate Publication Site + +`validate-publication-site` verifies an already-built static publication tree. +It never checks out source, invokes a renderer, installs dependencies, changes +repository state, or deploys content. + +The action accepts the host-neutral `beacon.publication-hub/v1` catalog and +proves that its canonical HTTPS base, source revision, routes, publication-slot +honesty, artifact metadata, and complete sorted `SHA256SUMS` inventory agree +with the bytes in the caller workspace. It rejects broad or protected output +directories, path traversal, symlinks, route collisions, empty files, missing +files, partial or mutable inventories, unsafe public hosts, and publication +claims on planned slots. Public fallback fields are present but may be `null`. +Available slots require a pinned source revision, version, and at least one +verified artifact. + +The action applies the exact vendored +[`publication-site.schema.json`](contracts/publication-site.schema.json) before +Relay's byte, route, host, and lifecycle checks. Its pinned upstream provenance +and digest are recorded in the [contract README](contracts/README.md). + +```yaml +- name: Validate the product-owned publication site + # Relay v1.3.0; pin the released full commit SHA. + uses: egohygiene/relay/actions/validate-publication-site@ + with: + site-directory: "_site" + expected-base-url: "https://antidote.egohygiene.io/" + expected-fallback-base-url: "https://egohygiene.github.io/antidote/" + expected-source-revision: "${{ github.sha }}" + required-routes: '["", "paper/", "magazine/", "downloads/"]' +``` + +The generated validation evidence is deterministic and contains no local +absolute paths. The default destination is +`.relay/publication-site-validation.json`; callers may upload it as ordinary +review evidence. Its successful and sanitized failure shapes are versioned by +[`publication-site-validation-evidence.schema.json`](schemas/publication-site-validation-evidence.schema.json). + +An empty `expected-fallback-base-url` accepts nullable fallback metadata without +pinning it. When supplied, the value must exactly match the normalized catalog +fallback; the reusable workflow passes its fallback input during read-only +review so drift fails before deployment. + +`site-tree-sha256` is the SHA-256 digest of the exact, sorted, complete +`SHA256SUMS` bytes. Because that inventory covers every public file except +itself, the digest is a stable identity for the reviewed site tree. + +Relay caps the site at 20,000 files, 1 GiB total, and 256 MiB per file, with +tighter bounds for the catalog and checksum metadata. These are validation +limits, not permission to expose sensitive content: the caller remains +responsible for deciding which product-owned bytes are public. + +The catalog's `source.catalog_sha256` identifies Beacon's private authored +`publication-hub.json`. Relay validates that digest's shape, but the private +source is deliberately outside a public static artifact. Relay instead proves +the public `site.json` bytes through the complete outer checksum inventory; +attesting the private authored source remains caller-owned evidence. diff --git a/actions/validate-publication-site/action.yml b/actions/validate-publication-site/action.yml new file mode 100644 index 0000000..a64dda1 --- /dev/null +++ b/actions/validate-publication-site/action.yml @@ -0,0 +1,95 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json + +name: "Validate Publication Site" +description: >- + Validate a complete caller-built publication site, its Beacon catalog, + routes, and exact checksum inventory without rendering or deploying content. + +inputs: + site-directory: + description: "Workspace-relative directory containing the complete static site." + required: true + expected-base-url: + description: "Canonical HTTPS base URL expected in site.json." + required: true + expected-source-revision: + description: "Full lowercase source commit expected in site.json." + required: true + expected-fallback-base-url: + description: "Optional explicitly pinned fallback URL; empty accepts catalog metadata." + required: false + default: "" + required-routes: + description: "JSON array of site-relative routes that must be declared and present." + required: false + default: "[]" + catalog-path: + description: "Site-relative Beacon publication catalog path." + required: false + default: "site.json" + checksum-path: + description: "Site-relative complete SHA-256 inventory path." + required: false + default: "SHA256SUMS" + evidence-output: + description: "Workspace-relative destination for deterministic validation evidence." + required: false + default: ".relay/publication-site-validation.json" + +outputs: + canonical-base-url: + description: "Validated canonical HTTPS base URL." + value: "${{ steps.validate.outputs.canonical-base-url }}" + fallback-base-url: + description: "Validated technical fallback base URL, when declared." + value: "${{ steps.validate.outputs.fallback-base-url }}" + file-count: + description: "Number of files in the complete publication site." + value: "${{ steps.validate.outputs.file-count }}" + route-count: + description: "Number of declared publication routes." + value: "${{ steps.validate.outputs.route-count }}" + site-tree-sha256: + description: "SHA-256 digest of the exact complete checksum inventory." + value: "${{ steps.validate.outputs.site-tree-sha256 }}" + source-revision: + description: "Validated source revision." + value: "${{ steps.validate.outputs.source-revision }}" + total-bytes: + description: "Total bytes in the complete publication site." + value: "${{ steps.validate.outputs.total-bytes }}" + +branding: + icon: "check-circle" + color: "green" + +runs: + using: "composite" + steps: + - name: Validate caller-built publication site + id: validate + shell: bash + env: + INPUT_CATALOG_PATH: "${{ inputs.catalog-path }}" + INPUT_CHECKSUM_PATH: "${{ inputs.checksum-path }}" + INPUT_EVIDENCE_OUTPUT: "${{ inputs.evidence-output }}" + INPUT_EXPECTED_BASE_URL: "${{ inputs.expected-base-url }}" + INPUT_EXPECTED_FALLBACK_BASE_URL: "${{ inputs.expected-fallback-base-url }}" + INPUT_EXPECTED_SOURCE_REVISION: "${{ inputs.expected-source-revision }}" + INPUT_REQUIRED_ROUTES: "${{ inputs.required-routes }}" + INPUT_SITE_DIRECTORY: "${{ inputs.site-directory }}" + run: | + set -euo pipefail + python3 "${GITHUB_ACTION_PATH}/scripts/validate_publication_site.py" \ + --workspace "${GITHUB_WORKSPACE}" \ + --site-directory "${INPUT_SITE_DIRECTORY}" \ + --expected-base-url "${INPUT_EXPECTED_BASE_URL}" \ + --expected-fallback-base-url "${INPUT_EXPECTED_FALLBACK_BASE_URL}" \ + --expected-source-revision "${INPUT_EXPECTED_SOURCE_REVISION}" \ + --required-routes "${INPUT_REQUIRED_ROUTES}" \ + --catalog-path "${INPUT_CATALOG_PATH}" \ + --checksum-path "${INPUT_CHECKSUM_PATH}" \ + --evidence-output "${INPUT_EVIDENCE_OUTPUT}" \ + --github-output "${GITHUB_OUTPUT}" diff --git a/actions/validate-publication-site/contracts/README.md b/actions/validate-publication-site/contracts/README.md new file mode 100644 index 0000000..6919caa --- /dev/null +++ b/actions/validate-publication-site/contracts/README.md @@ -0,0 +1,27 @@ +# Beacon publication-site contract + +[`publication-site.schema.json`](publication-site.schema.json) is an exact copy +of Beacon's frozen `beacon.publication-hub/v1` public-catalog schema for the +Relay v1.3.0 handoff. + +- Upstream path: `templates/publication-hub/contracts/publication-site.schema.json` +- Upstream issue: `egohygiene/beacon#19` +- Schema SHA-256: + `458cfcdf8f24ef2c702ce592ad329cdb1a7c88954df6c7fcfe2f143d01b9c112` +- Relay adoption issue: `egohygiene/relay#38` + +Relay vendors the contract so a called action validates the exact schema from +its immutable Relay revision without checking out Beacon or caller source. The +stdlib validator implements the schema's closed shapes, conditionals, and +reference rules, then adds deployment-boundary checks for safe public hosts, +normalized paths, exact route-to-file linkage, staged bytes, complete sorted +checksums, bounded input, and caller-pinned revision/base URLs. + +`source.catalog_sha256` is the digest of Beacon's private authored +`publication-hub.json`. Relay can validate its syntax but cannot recompute it +from the public deployment tree. The caller owns that private-source +attestation; Relay binds the public `site.json` itself through `SHA256SUMS`. + +An intentional contract update must copy the reviewed upstream bytes, update +this digest, add compatibility mutations, and ship in an appropriate Relay +release. It must not silently follow Beacon's default branch. diff --git a/actions/validate-publication-site/contracts/publication-site.schema.json b/actions/validate-publication-site/contracts/publication-site.schema.json new file mode 100644 index 0000000..84b4615 --- /dev/null +++ b/actions/validate-publication-site/contracts/publication-site.schema.json @@ -0,0 +1,363 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.io/beacon/contracts/publication-hub/v1", + "title": "Beacon public publication-hub catalog", + "description": "Deterministic public routes, slots, and byte-verified resources for deployment adapters.", + "type": "object", + "required": ["schema", "schema_version", "source", "site", "routes", "slots"], + "properties": { + "schema": { "const": "beacon.publication-hub/v1" }, + "schema_version": { "const": "1.0.0" }, + "source": { + "type": "object", + "required": ["catalog_sha256", "revision"], + "properties": { + "catalog_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "repository": { "type": "string", "format": "uri", "pattern": "^https://" }, + "revision": { + "anyOf": [ + { "const": "WORKING_TREE" }, + { "type": "string", "pattern": "^[0-9a-f]{40}$" } + ] + } + }, + "additionalProperties": false + }, + "site": { "$ref": "#/$defs/site" }, + "routes": { + "type": "array", + "minItems": 5, + "items": { "$ref": "#/$defs/route" } + }, + "slots": { + "type": "array", + "items": { "$ref": "#/$defs/slot" } + }, + "extensions": { "$ref": "#/$defs/extensions" } + }, + "additionalProperties": false, + "$defs": { + "identifier": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "httpsUrl": { "type": "string", "format": "uri", "pattern": "^https://" }, + "nullableHttpsUrl": { + "anyOf": [{ "$ref": "#/$defs/httpsUrl" }, { "type": "null" }] + }, + "relativeFile": { + "type": "string", + "minLength": 1, + "not": { "pattern": "^(?:/|.*(?:^|/)\\.\\.(?:/|$))" } + }, + "extensions": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/identifier" } + }, + "site": { + "type": "object", + "required": [ + "id", + "title", + "description", + "language", + "stage", + "publisher", + "theme", + "canonical_base_url", + "fallback_base_url", + "revision" + ], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "language": { "type": "string", "minLength": 2 }, + "stage": { "enum": ["draft", "published", "archived"] }, + "publisher": { "type": "string", "minLength": 1 }, + "theme": { "enum": ["neutral", "egohygiene"] }, + "canonical_base_url": { "$ref": "#/$defs/httpsUrl" }, + "fallback_base_url": { "$ref": "#/$defs/nullableHttpsUrl" }, + "repository": { "$ref": "#/$defs/httpsUrl" }, + "revision": { + "anyOf": [ + { "const": "WORKING_TREE" }, + { "type": "string", "pattern": "^[0-9a-f]{40}$" } + ] + }, + "extensions": { "$ref": "#/$defs/extensions" } + }, + "additionalProperties": false + }, + "route": { + "type": "object", + "required": ["kind", "id", "path", "url", "fallback_url"], + "properties": { + "kind": { "$ref": "#/$defs/identifier" }, + "id": { "type": "string", "minLength": 1 }, + "path": { "type": "string" }, + "file": { "$ref": "#/$defs/relativeFile" }, + "url": { "$ref": "#/$defs/httpsUrl" }, + "fallback_url": { "$ref": "#/$defs/nullableHttpsUrl" } + }, + "additionalProperties": false + }, + "reference": { + "type": "object", + "required": ["label", "url"], + "properties": { + "label": { "type": "string", "minLength": 1 }, + "url": { "$ref": "#/$defs/httpsUrl" }, + "fallback_url": { "$ref": "#/$defs/nullableHttpsUrl" }, + "resource_id": { "$ref": "#/$defs/identifier" }, + "media_type": { "type": "string", "pattern": "^[^/]+/[^/]+$" }, + "path": { "$ref": "#/$defs/relativeFile" }, + "bytes": { "type": "integer", "minimum": 1 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "oneOf": [ + { + "not": { + "anyOf": [ + { "required": ["resource_id"] }, + { "required": ["fallback_url"] }, + { "required": ["path"] }, + { "required": ["bytes"] }, + { "required": ["sha256"] } + ] + } + }, + { + "required": [ + "resource_id", + "fallback_url", + "media_type", + "path", + "bytes", + "sha256" + ] + } + ], + "additionalProperties": false + }, + "landing": { + "oneOf": [ + { + "type": "object", + "required": ["mode"], + "properties": { "mode": { "const": "generated" } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["mode", "resource_id"], + "properties": { + "mode": { "const": "resource" }, + "resource_id": { "$ref": "#/$defs/identifier" } + }, + "additionalProperties": false + } + ] + }, + "sourceRecord": { + "type": "object", + "required": ["repository", "revision"], + "properties": { + "repository": { "$ref": "#/$defs/httpsUrl" }, + "revision": { + "anyOf": [ + { "const": "WORKING_TREE" }, + { "type": "string", "pattern": "^[0-9a-f]{40}$" } + ] + }, + "path": { "$ref": "#/$defs/relativeFile" } + }, + "additionalProperties": false + }, + "publicationIdentifier": { + "type": "object", + "required": ["scheme", "value"], + "properties": { + "scheme": { "$ref": "#/$defs/identifier" }, + "value": { "type": "string", "minLength": 1 }, + "url": { "$ref": "#/$defs/httpsUrl" } + }, + "additionalProperties": false + }, + "release": { + "type": "object", + "required": ["url"], + "properties": { + "url": { "$ref": "#/$defs/httpsUrl" }, + "published_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } + }, + "additionalProperties": false + }, + "accessibility": { + "type": "object", + "required": ["summary", "features"], + "properties": { + "summary": { "type": "string", "minLength": 1 }, + "features": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "conformance": { "type": "string", "minLength": 1 }, + "report": { "$ref": "#/$defs/reference" } + }, + "additionalProperties": false + }, + "alias": { + "type": "object", + "required": ["path", "url", "fallback_url"], + "properties": { + "path": { "type": "string", "pattern": "^[^/]+$" }, + "url": { "$ref": "#/$defs/httpsUrl" }, + "fallback_url": { "$ref": "#/$defs/nullableHttpsUrl" } + }, + "additionalProperties": false + }, + "slot": { + "type": "object", + "required": [ + "id", + "kind", + "title", + "summary", + "status", + "order", + "route", + "url", + "fallback_url", + "landing" + ], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "kind": { "$ref": "#/$defs/identifier" }, + "title": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 }, + "status": { + "enum": ["planned", "draft", "available", "superseded", "withdrawn"] + }, + "order": { "type": "integer" }, + "route": { "type": "string", "pattern": "^[^/].*/$" }, + "url": { "$ref": "#/$defs/httpsUrl" }, + "fallback_url": { "$ref": "#/$defs/nullableHttpsUrl" }, + "landing": { "$ref": "#/$defs/landing" }, + "version": { "type": "string", "minLength": 1 }, + "source": { "$ref": "#/$defs/sourceRecord" }, + "identifiers": { + "type": "array", + "items": { "$ref": "#/$defs/publicationIdentifier" }, + "uniqueItems": true + }, + "release": { "$ref": "#/$defs/release" }, + "artifacts": { "type": "array", "items": { "$ref": "#/$defs/resource" } }, + "manifests": { + "type": "array", + "items": { "$ref": "#/$defs/manifestResource" } + }, + "preview": { "$ref": "#/$defs/reference" }, + "provenance": { "type": "array", "items": { "$ref": "#/$defs/reference" } }, + "accessibility": { "$ref": "#/$defs/accessibility" }, + "superseded_by": { "type": "string", "minLength": 1 }, + "withdrawal_notice": { "type": "string", "minLength": 1 }, + "extensions": { "$ref": "#/$defs/extensions" } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "planned" } }, "required": ["status"] }, + "then": { + "not": { + "anyOf": [ + { "required": ["artifacts"] }, + { "required": ["manifests"] }, + { "required": ["version"] }, + { "required": ["identifiers"] }, + { "required": ["release"] }, + { "required": ["preview"] }, + { "required": ["provenance"] }, + { "required": ["source"] } + ] + } + } + }, + { + "if": { "properties": { "status": { "const": "available" } }, "required": ["status"] }, + "then": { + "required": ["version", "source", "artifacts"], + "properties": { "artifacts": { "minItems": 1 } } + } + }, + { + "if": { + "properties": { "status": { "enum": ["draft", "withdrawn"] } }, + "required": ["status"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["artifacts"] }, + { "required": ["manifests"] } + ] + } + } + }, + { + "if": { "properties": { "status": { "const": "superseded" } }, "required": ["status"] }, + "then": { "required": ["superseded_by"] } + }, + { + "if": { "properties": { "status": { "const": "withdrawn" } }, "required": ["status"] }, + "then": { "required": ["withdrawal_notice"] } + } + ], + "additionalProperties": false + }, + "resource": { + "type": "object", + "required": [ + "id", + "label", + "media_type", + "path", + "route", + "bytes", + "sha256", + "url", + "fallback_url", + "aliases" + ], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "label": { "type": "string", "minLength": 1 }, + "media_type": { "type": "string", "pattern": "^[^/]+/[^/]+$" }, + "path": { "$ref": "#/$defs/relativeFile" }, + "route": { "type": "string", "minLength": 1 }, + "bytes": { "type": "integer", "minimum": 1 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "url": { "$ref": "#/$defs/httpsUrl" }, + "fallback_url": { "$ref": "#/$defs/nullableHttpsUrl" }, + "aliases": { + "type": "array", + "items": { "$ref": "#/$defs/alias" }, + "uniqueItems": true + }, + "extensions": { "$ref": "#/$defs/extensions" } + }, + "additionalProperties": false + }, + "manifestResource": { + "allOf": [ + { "$ref": "#/$defs/resource" }, + { + "properties": { + "aliases": { "type": "array", "maxItems": 0 } + } + } + ] + } + } +} diff --git a/actions/validate-publication-site/schemas/publication-site-validation-evidence.schema.json b/actions/validate-publication-site/schemas/publication-site-validation-evidence.schema.json new file mode 100644 index 0000000..55fbf09 --- /dev/null +++ b/actions/validate-publication-site/schemas/publication-site-validation-evidence.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/relay/contracts/publication-site-validation-evidence/v1/schema.json", + "title": "Relay publication-site validation evidence v1", + "oneOf": [ + {"$ref": "#/$defs/success"}, + {"$ref": "#/$defs/failure"} + ], + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "success": { + "type": "object", + "additionalProperties": false, + "required": [ + "canonical_base_url", + "catalog", + "catalog_schema", + "checksum_manifest", + "fallback_base_url", + "file_count", + "route_count", + "routes", + "schema", + "site_tree_sha256", + "source_revision", + "status", + "total_bytes" + ], + "properties": { + "canonical_base_url": {"type": "string", "format": "uri"}, + "catalog": {"$ref": "#/$defs/resource"}, + "catalog_schema": {"const": "beacon.publication-hub/v1"}, + "checksum_manifest": {"$ref": "#/$defs/resource"}, + "fallback_base_url": { + "oneOf": [ + {"type": "null"}, + {"type": "string", "format": "uri"} + ] + }, + "file_count": {"type": "integer", "minimum": 1}, + "route_count": {"type": "integer", "minimum": 1}, + "routes": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, + "schema": {"const": "egohygiene.relay.publication-site-validation/v1"}, + "site_tree_sha256": {"$ref": "#/$defs/sha256"}, + "source_revision": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "status": {"const": "passed"}, + "total_bytes": {"type": "integer", "minimum": 1} + } + }, + "failure": { + "type": "object", + "additionalProperties": false, + "required": ["error", "reason", "schema", "stage", "status"], + "properties": { + "error": {"const": "publication-site-validation-failed"}, + "reason": {"const": "contract-rejected"}, + "schema": {"const": "egohygiene.relay.publication-site-validation/v1"}, + "stage": {"const": "local-validation"}, + "status": {"const": "failed"} + } + }, + "resource": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": {"type": "string", "minLength": 1}, + "sha256": {"$ref": "#/$defs/sha256"} + } + } + } +} diff --git a/actions/validate-publication-site/scripts/validate_publication_site.py b/actions/validate-publication-site/scripts/validate_publication_site.py new file mode 100644 index 0000000..8ffd1a4 --- /dev/null +++ b/actions/validate-publication-site/scripts/validate_publication_site.py @@ -0,0 +1,1627 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT + +"""Validate a complete, caller-built publication site without rendering it.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +import ipaddress +import json +import os +from pathlib import Path, PurePosixPath +import re +import sys +from typing import Any, Iterable +from urllib.parse import urljoin, urlsplit, urlunsplit + + +CATALOG_SCHEMA = "beacon.publication-hub/v1" +PUBLIC_SCHEMA_PATH = ( + Path(__file__).resolve().parents[1] + / "contracts" + / "publication-site.schema.json" +) +PUBLIC_SCHEMA_SHA256 = ( + "458cfcdf8f24ef2c702ce592ad329cdb1a7c88954df6c7fcfe2f143d01b9c112" +) +EVIDENCE_SCHEMA = "egohygiene.relay.publication-site-validation/v1" +SLOT_STATUSES = {"planned", "draft", "available", "superseded", "withdrawn"} +SHA256 = re.compile(r"^[0-9a-f]{64}$") +REVISION = re.compile(r"^[0-9a-f]{40}$") +SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9._~-]+$") +IDENTIFIER = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +PROTECTED_SITE_ROOTS = { + ".git", + ".github", + ".ssh", + "actions", + "scripts", + "tests", +} +MAX_SITE_FILES = 20_000 +MAX_FILE_BYTES = 256 * 1024 * 1024 +MAX_SITE_BYTES = 1024 * 1024 * 1024 +MAX_CATALOG_BYTES = 8 * 1024 * 1024 +MAX_CHECKSUM_BYTES = 16 * 1024 * 1024 +MAX_DIAGNOSTIC_PATHS = 5 +MAX_DIAGNOSTIC_PATH_LENGTH = 96 +PLANNED_PUBLICATION_FIELDS = { + "artifacts", + "bytes", + "checksum", + "cover", + "cover_url", + "date_published", + "doi", + "identifiers", + "issue", + "issue_number", + "manifest", + "manifests", + "preview", + "provenance", + "publication_date", + "published_at", + "release", + "release_date", + "release_url", + "sha256", + "source", + "version", + "zenodo", +} +CATALOG_KEYS = { + "schema", + "schema_version", + "source", + "site", + "routes", + "slots", + "extensions", +} +SOURCE_KEYS = {"catalog_sha256", "repository", "revision"} +SITE_KEYS = { + "id", + "title", + "description", + "language", + "stage", + "publisher", + "theme", + "canonical_base_url", + "fallback_base_url", + "repository", + "revision", + "extensions", +} +ROUTE_KEYS = {"kind", "id", "path", "url", "fallback_url", "file"} +ROUTE_KINDS = { + "hub", + "downloads", + "catalog", + "checksum", + "web-manifest", + "slot", + "artifact", + "manifest", + "artifact-alias", +} +RESOURCE_KEYS = { + "id", + "label", + "media_type", + "path", + "route", + "bytes", + "sha256", + "url", + "fallback_url", + "aliases", + "extensions", +} +ALIAS_KEYS = {"path", "url", "fallback_url"} +LANDING_KEYS = {"mode", "resource_id"} +SLOT_SOURCE_KEYS = {"repository", "revision", "path"} +REFERENCE_KEYS = { + "label", + "url", + "fallback_url", + "resource_id", + "media_type", + "path", + "bytes", + "sha256", +} +IDENTIFIER_KEYS = {"scheme", "value", "url"} +RELEASE_KEYS = {"url", "published_at"} +ACCESSIBILITY_KEYS = {"summary", "features", "conformance", "report"} +SLOT_KEYS = { + "accessibility", + "artifacts", + "extensions", + "fallback_url", + "id", + "identifiers", + "kind", + "landing", + "manifests", + "order", + "preview", + "provenance", + "release", + "route", + "source", + "status", + "summary", + "superseded_by", + "title", + "url", + "version", + "withdrawal_notice", +} +MEDIA_TYPE = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$" +) + + +class ValidationError(ValueError): + """A publication artifact violates the Relay boundary.""" + + +def summarize_paths(paths: Iterable[str]) -> str: + """Return a bounded, control-free diagnostic summary of public paths.""" + + values = sorted(paths) + sample = [ + value[:MAX_DIAGNOSTIC_PATH_LENGTH] + for value in values[:MAX_DIAGNOSTIC_PATHS] + ] + suffix = ", ..." if len(values) > len(sample) else "" + return f"count={len(values)}, sample=[{', '.join(sample)}{suffix}]" + + +@dataclass(frozen=True) +class ValidationResult: + """Stable facts proven about one local publication site.""" + + canonical_base_url: str + fallback_base_url: str | None + source_revision: str + site_tree_sha256: str + catalog_sha256: str + checksum_sha256: str + catalog_path: str + checksum_path: str + routes: tuple[str, ...] + route_files: tuple[tuple[str, str], ...] + files: tuple[tuple[str, str], ...] + file_count: int + total_bytes: int + + def evidence(self) -> dict[str, Any]: + """Return deterministic validation evidence without local filesystem paths.""" + + return { + "canonical_base_url": self.canonical_base_url, + "catalog": { + "path": self.catalog_path, + "sha256": self.catalog_sha256, + }, + "catalog_schema": CATALOG_SCHEMA, + "checksum_manifest": { + "path": self.checksum_path, + "sha256": self.checksum_sha256, + }, + "fallback_base_url": self.fallback_base_url, + "file_count": self.file_count, + "route_count": len(self.routes), + "routes": list(self.routes), + "schema": EVIDENCE_SCHEMA, + "site_tree_sha256": self.site_tree_sha256, + "source_revision": self.source_revision, + "status": "passed", + "total_bytes": self.total_bytes, + } + + +def sha256_bytes(value: bytes) -> str: + """Return the lowercase SHA-256 digest of bytes.""" + + return hashlib.sha256(value).hexdigest() + + +def sha256_file(path: Path) -> str: + """Stream a file into SHA-256.""" + + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def validate_vendored_contract() -> None: + """Fail closed if the immutable Beacon v1 schema package drifts.""" + + if ( + not PUBLIC_SCHEMA_PATH.is_file() + or sha256_file(PUBLIC_SCHEMA_PATH) != PUBLIC_SCHEMA_SHA256 + ): + raise ValidationError("vendored Beacon publication-site contract is missing or changed") + + +def load_object(path: Path, label: str) -> dict[str, Any]: + """Load a JSON object with a bounded, readable failure.""" + + try: + def strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, child in pairs: + if key in result: + raise ValidationError(f"{label} contains a duplicate key") + result[key] = child + return result + + def reject_constant(_: str) -> None: + raise ValidationError(f"{label} contains a non-finite JSON number") + + value = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=strict_object, + parse_constant=reject_constant, + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, RecursionError) as error: + raise ValidationError(f"{label} is not valid UTF-8 JSON: {error}") from error + if not isinstance(value, dict): + raise ValidationError(f"{label} must contain a JSON object") + return value + + +def _schema_type_matches(value: Any, expected: str) -> bool: + """Implement the JSON types used by Beacon's frozen public schema.""" + + return { + "array": lambda item: isinstance(item, list), + "boolean": lambda item: isinstance(item, bool), + "integer": lambda item: isinstance(item, int) and not isinstance(item, bool), + "null": lambda item: item is None, + "number": lambda item: isinstance(item, (int, float)) + and not isinstance(item, bool), + "object": lambda item: isinstance(item, dict), + "string": lambda item: isinstance(item, str), + }.get(expected, lambda _item: False)(value) + + +def _schema_ref(root: dict[str, Any], reference: str) -> dict[str, Any]: + """Resolve only local JSON pointers from the vendored schema.""" + + if not reference.startswith("#/"): + raise ValidationError("vendored schema uses an external reference") + current: Any = root + for token in reference[2:].split("/"): + token = token.replace("~1", "/").replace("~0", "~") + if not isinstance(current, dict) or token not in current: + raise ValidationError("vendored schema contains an unresolved reference") + current = current[token] + if not isinstance(current, dict): + raise ValidationError("vendored schema reference is not an object") + return current + + +def _schema_passes( + value: Any, + schema: dict[str, Any], + root: dict[str, Any], +) -> bool: + """Return whether one value matches one supported subschema.""" + + try: + _validate_schema_value(value, schema, root, "site catalog") + except ValidationError: + return False + return True + + +def _validate_schema_value( + value: Any, + schema: dict[str, Any], + root: dict[str, Any], + field: str, +) -> None: + """Validate the exact stdlib-only JSON Schema subset used by Beacon.""" + + if "$ref" in schema: + _validate_schema_value(value, _schema_ref(root, schema["$ref"]), root, field) + return + for subschema in schema.get("allOf", []): + _validate_schema_value(value, subschema, root, field) + if "anyOf" in schema and not any( + _schema_passes(value, subschema, root) for subschema in schema["anyOf"] + ): + raise ValidationError(f"{field} does not match an allowed schema shape") + if "oneOf" in schema: + matches = sum( + _schema_passes(value, subschema, root) for subschema in schema["oneOf"] + ) + if matches != 1: + raise ValidationError(f"{field} must match exactly one schema shape") + if "not" in schema and _schema_passes(value, schema["not"], root): + raise ValidationError(f"{field} matches a forbidden schema shape") + if "if" in schema and _schema_passes(value, schema["if"], root): + if "then" in schema: + _validate_schema_value(value, schema["then"], root, field) + elif "else" in schema: + _validate_schema_value(value, schema["else"], root, field) + + expected = schema.get("type") + if expected is not None: + expected_types = [expected] if isinstance(expected, str) else expected + if not isinstance(expected_types, list) or not any( + isinstance(item, str) and _schema_type_matches(value, item) + for item in expected_types + ): + raise ValidationError(f"{field} has the wrong JSON type") + if "const" in schema and value != schema["const"]: + raise ValidationError(f"{field} does not match the schema constant") + if "enum" in schema and value not in schema["enum"]: + raise ValidationError(f"{field} is not one of the allowed values") + + if isinstance(value, str): + if len(value) < schema.get("minLength", 0): + raise ValidationError(f"{field} is shorter than the schema minimum") + pattern = schema.get("pattern") + if pattern is not None and re.search(pattern, value) is None: + raise ValidationError(f"{field} does not match its schema pattern") + if isinstance(value, (int, float)) and not isinstance(value, bool): + if "minimum" in schema and value < schema["minimum"]: + raise ValidationError(f"{field} is below the schema minimum") + if isinstance(value, list): + if len(value) < schema.get("minItems", 0): + raise ValidationError(f"{field} has too few items") + if "maxItems" in schema and len(value) > schema["maxItems"]: + raise ValidationError(f"{field} has too many items") + if schema.get("uniqueItems"): + canonical = [ + json.dumps(item, sort_keys=True, allow_nan=False) for item in value + ] + if len(canonical) != len(set(canonical)): + raise ValidationError(f"{field} must contain unique items") + item_schema = schema.get("items") + if isinstance(item_schema, dict): + for index, item in enumerate(value): + _validate_schema_value(item, item_schema, root, f"{field}[{index}]") + if isinstance(value, dict): + required = schema.get("required", []) + missing = [key for key in required if key not in value] + if missing: + raise ValidationError(f"{field} is missing required schema fields") + property_names = schema.get("propertyNames") + if isinstance(property_names, dict): + for key in value: + _validate_schema_value(key, property_names, root, f"{field} key") + properties = schema.get("properties", {}) + for key, child in value.items(): + if key in properties: + _validate_schema_value(child, properties[key], root, f"{field}.{key}") + elif schema.get("additionalProperties") is False: + raise ValidationError(f"{field} has an unsupported schema field") + elif isinstance(schema.get("additionalProperties"), dict): + _validate_schema_value( + child, + schema["additionalProperties"], + root, + f"{field}.{key}", + ) + + +def validate_public_schema(value: dict[str, Any]) -> None: + """Apply Beacon's exact frozen public schema before Relay semantics.""" + + schema = load_object(PUBLIC_SCHEMA_PATH, "vendored Beacon schema") + try: + _validate_schema_value(value, schema, schema, "site catalog") + except RecursionError as error: + raise ValidationError("site catalog exceeds the schema nesting limit") from error + + +def require_string(value: Any, label: str) -> str: + """Return one non-empty string.""" + + if not isinstance(value, str) or not value.strip(): + raise ValidationError(f"{label} must be a non-empty string") + return value + + +def require_identifier(value: Any, label: str) -> str: + """Return one lowercase kebab-case public identifier.""" + + identifier = require_string(value, label) + if not IDENTIFIER.fullmatch(identifier): + raise ValidationError(f"{label} must be lowercase kebab-case") + return identifier + + +def validate_extensions(value: Any, label: str) -> None: + """Require a namespaced extension object with safe top-level keys.""" + + if not isinstance(value, dict): + raise ValidationError(f"{label} must be an object") + if any(not isinstance(key, str) or not IDENTIFIER.fullmatch(key) for key in value): + raise ValidationError(f"{label} keys must be lowercase kebab-case") + + +def require_known_keys( + value: dict[str, Any], + *, + allowed: set[str], + required: set[str], + label: str, +) -> None: + """Require the exact key surface of the versioned public contract.""" + + missing = sorted(required - set(value)) + unknown = sorted(set(value) - allowed) + if missing: + raise ValidationError(f"{label} is missing required fields: {missing}") + if unknown: + raise ValidationError(f"{label} contains unsupported fields") + + +def reject_reserved_extension_keys(value: Any, label: str) -> None: + """Prevent nested extensions from bypassing planned-slot honesty rules.""" + + pending = [value] + while pending: + current = pending.pop() + if isinstance(current, dict): + for key, child in current.items(): + normalized = key.lower().replace("-", "_") + if normalized in PLANNED_PUBLICATION_FIELDS: + raise ValidationError( + f"{label} cannot carry a reserved publication key" + ) + pending.append(child) + elif isinstance(current, list): + pending.extend(current) + + +def relative_path(value: Any, label: str, *, allow_root: bool = False) -> str: + """Normalize a portable site-relative POSIX path.""" + + if not isinstance(value, str): + raise ValidationError(f"{label} must be a string") + if value == "" and allow_root: + return value + if not value or "\\" in value or "\x00" in value or "%" in value: + raise ValidationError(f"{label} is not a safe portable path") + if value.startswith("/") or "//" in value: + raise ValidationError(f"{label} must be relative and normalized") + trailing_slash = value.endswith("/") + parts = PurePosixPath(value).parts + if not parts or any( + part in {"", ".", ".."} or not SAFE_SEGMENT.fullmatch(part) + for part in parts + ): + raise ValidationError(f"{label} is not a safe portable path") + normalized = PurePosixPath(*parts).as_posix() + if trailing_slash: + normalized += "/" + if normalized != value: + raise ValidationError(f"{label} must be normalized") + return normalized + + +def normalize_base_url(value: Any, label: str) -> str: + """Require a clean HTTPS base URL ending in one slash.""" + + url = require_string(value, label) + if any(ord(character) < 33 or ord(character) == 127 for character in url): + raise ValidationError(f"{label} contains whitespace or control characters") + try: + parsed = urlsplit(url) + port = parsed.port + except ValueError as error: + raise ValidationError(f"{label} is malformed") from error + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValidationError(f"{label} must be a clean HTTPS URL") + if "\\" in parsed.path or "%" in parsed.path or "//" in parsed.path: + raise ValidationError(f"{label} contains an ambiguous path") + segments = [segment for segment in parsed.path.split("/") if segment] + if any( + segment in {".", ".."} or not SAFE_SEGMENT.fullmatch(segment) + for segment in segments + ): + raise ValidationError(f"{label} contains an unsafe path segment") + path = "/" + "/".join(segments) if segments else "/" + if not path.endswith("/"): + path += "/" + host = parsed.hostname.lower() if parsed.hostname else "" + if port not in {None, 443}: + raise ValidationError(f"{label} must use the standard HTTPS port") + if host == "localhost" or host.endswith(".localhost"): + raise ValidationError(f"{label} must use a public DNS hostname") + try: + ipaddress.ip_address(host) + except ValueError: + pass + else: + raise ValidationError(f"{label} cannot use an IP literal") + try: + host.encode("ascii") + except UnicodeEncodeError as error: + raise ValidationError(f"{label} hostname must be ASCII/punycode") from error + labels = host.split(".") + if ( + len(labels) < 2 + or len(host) > 253 + or any( + len(host_label) > 63 + or not re.fullmatch( + r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", host_label + ) + for host_label in labels + ) + ): + raise ValidationError(f"{label} must use a valid public DNS hostname") + normalized = urlunsplit(("https", host, path, "", "")) + if url != normalized: + raise ValidationError(f"{label} must use its normalized HTTPS form") + return normalized + + +def validate_https_url(value: Any, label: str) -> str: + """Require a standard-port HTTPS URL on a public-looking DNS host.""" + + url = require_string(value, label) + if any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in url): + raise ValidationError(f"{label} contains whitespace or control characters") + try: + parsed = urlsplit(url) + port = parsed.port + except ValueError as error: + raise ValidationError(f"{label} is malformed") from error + if ( + parsed.scheme != "https" + or not parsed.netloc + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or port not in {None, 443} + ): + raise ValidationError(f"{label} must be a public standard-port HTTPS URL") + host = parsed.hostname.lower() + if host == "localhost" or host.endswith(".localhost"): + raise ValidationError(f"{label} must use a public DNS hostname") + try: + ipaddress.ip_address(host) + except ValueError: + pass + else: + raise ValidationError(f"{label} cannot use an IP literal") + labels = host.split(".") + if ( + len(labels) < 2 + or len(host) > 253 + or any( + len(host_label) > 63 + or not re.fullmatch( + r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", host_label + ) + for host_label in labels + ) + ): + raise ValidationError(f"{label} must use a valid public DNS hostname") + return url + + +def validate_public_reference( + value: Any, + *, + label: str, + resources: dict[str, dict[str, Any]], +) -> None: + """Validate an external or byte-bound slot reference.""" + + if not isinstance(value, dict): + raise ValidationError(f"{label} must be an object") + require_known_keys( + value, + allowed=REFERENCE_KEYS, + required={"label", "url"}, + label=label, + ) + require_string(value.get("label"), f"{label}.label") + validate_https_url(value.get("url"), f"{label}.url") + if "media_type" in value and not MEDIA_TYPE.fullmatch( + require_string(value["media_type"], f"{label}.media_type") + ): + raise ValidationError(f"{label}.media_type is invalid") + resource_id = value.get("resource_id") + if resource_id is None: + forbidden = {"fallback_url", "path", "bytes", "sha256"} & set(value) + if forbidden: + raise ValidationError(f"{label} external reference carries resource fields") + return + identifier = require_identifier(resource_id, f"{label}.resource_id") + resource = resources.get(identifier) + if resource is None: + raise ValidationError(f"{label} references an unknown resource") + required_resource_fields = { + "fallback_url", + "media_type", + "path", + "bytes", + "sha256", + } + if not required_resource_fields.issubset(value): + raise ValidationError(f"{label} resource reference is incomplete") + for field in ("url", *sorted(required_resource_fields)): + if value.get(field) != resource.get(field): + raise ValidationError(f"{label} does not match its declared resource") + + +def route_entrypoint(path: str) -> str: + """Map a public route to the exact file GitHub Pages must serve.""" + + if not path: + return "index.html" + return f"{path}index.html" if path.endswith("/") else path + + +def resolve_site_directory(workspace: Path, value: str) -> Path: + """Resolve one owned output directory without accepting broad/protected roots.""" + + relative = relative_path(value, "site-directory") + first = PurePosixPath(relative).parts[0].lower() + if first in PROTECTED_SITE_ROOTS: + raise ValidationError(f"site-directory uses a protected root: {first}") + workspace = workspace.resolve() + site = workspace.joinpath(*PurePosixPath(relative).parts) + try: + resolved = site.resolve(strict=True) + except OSError as error: + raise ValidationError(f"site-directory does not exist: {relative}") from error + if resolved == workspace or workspace not in resolved.parents: + raise ValidationError("site-directory must be a child of the workspace") + if not resolved.is_dir(): + raise ValidationError("site-directory must be a directory") + current = workspace + for part in PurePosixPath(relative).parts: + current /= part + if current.is_symlink(): + raise ValidationError(f"site-directory has a symbolic-link ancestor: {relative}") + return resolved + + +def resolve_workspace_output(workspace: Path, value: str, *, site: Path) -> Path: + """Resolve a narrow evidence file outside protected and public roots.""" + + relative = relative_path(value, "evidence-output") + first = PurePosixPath(relative).parts[0].lower() + if first in PROTECTED_SITE_ROOTS: + raise ValidationError(f"evidence-output uses a protected root: {first}") + workspace = workspace.resolve() + output = workspace.joinpath(*PurePosixPath(relative).parts) + if output == site or site in output.parents: + raise ValidationError("evidence-output must be outside the public site") + current = workspace + for part in PurePosixPath(relative).parts: + current /= part + if current.is_symlink(): + raise ValidationError("evidence-output cannot traverse a symbolic link") + if output.exists() and (output.is_symlink() or not output.is_file()): + raise ValidationError("evidence-output must identify a regular file") + return output + + +def inventory_files(site: Path) -> dict[str, Path]: + """Return all regular site files and reject links or special entries.""" + + files: dict[str, Path] = {} + directories = [site] + entry_count = 0 + while directories: + directory = directories.pop() + try: + entries = [] + with os.scandir(directory) as scanned: + for entry in scanned: + entries.append(entry) + entry_count += 1 + if entry_count > MAX_SITE_FILES * 2: + raise ValidationError("publication site exceeds the entry-count limit") + entries.sort(key=lambda entry: entry.name) + except OSError as error: + raise ValidationError("publication site cannot be inventoried") from error + for entry in reversed(entries): + path = Path(entry.path) + relative = path.relative_to(site).as_posix() + if entry.is_symlink(): + raise ValidationError( + f"publication site cannot contain symbolic links: {relative}" + ) + if entry.is_dir(follow_symlinks=False): + directories.append(path) + continue + if not entry.is_file(follow_symlinks=False): + raise ValidationError( + f"publication site contains a special file: {relative}" + ) + relative_path(relative, f"site file {relative}") + size = entry.stat(follow_symlinks=False).st_size + if size > MAX_FILE_BYTES: + raise ValidationError( + f"publication site file exceeds size limit: {relative}" + ) + files[relative] = path + if len(files) > MAX_SITE_FILES: + raise ValidationError("publication site exceeds the file-count limit") + if not files: + raise ValidationError("publication site is empty") + if "index.html" not in files: + raise ValidationError("publication site is missing index.html") + if sum(path.stat().st_size for path in files.values()) > MAX_SITE_BYTES: + raise ValidationError("publication site exceeds the total-byte limit") + return files + + +def parse_required_routes(value: str) -> tuple[str, ...]: + """Parse the workflow-safe JSON array used for required public routes.""" + + try: + routes = json.loads(value) + except json.JSONDecodeError as error: + raise ValidationError("required-routes must be a JSON array of strings") from error + if not isinstance(routes, list) or any(not isinstance(item, str) for item in routes): + raise ValidationError("required-routes must be a JSON array of strings") + normalized = tuple(relative_path(item, "required route", allow_root=True) for item in routes) + if len(normalized) != len(set(normalized)): + raise ValidationError("required-routes contains duplicates") + return normalized + + +def parse_checksums( + site: Path, + checksum_path: str, + files: dict[str, Path], +) -> tuple[tuple[str, str], ...]: + """Verify a complete, sorted GNU-style SHA-256 inventory.""" + + manifest = files.get(checksum_path) + if manifest is None: + raise ValidationError(f"checksum manifest is missing: {checksum_path}") + try: + text = manifest.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + raise ValidationError("checksum manifest must be UTF-8 text") from error + if not text.endswith("\n"): + raise ValidationError("checksum manifest must end with a newline") + records: list[tuple[str, str]] = [] + seen: set[str] = set() + for line_number, line in enumerate(text.splitlines(), start=1): + match = re.fullmatch(r"([0-9a-f]{64}) (.+)", line) + if match is None: + raise ValidationError(f"malformed checksum line {line_number}") + digest, raw_path = match.groups() + path = relative_path(raw_path, f"checksum line {line_number}") + if path == checksum_path: + raise ValidationError("checksum manifest cannot checksum itself") + if path in seen: + raise ValidationError(f"duplicate checksum path: {path}") + seen.add(path) + records.append((path, digest)) + if [path for path, _ in records] != sorted(path for path, _ in records): + raise ValidationError("checksum manifest paths must be sorted") + expected_paths = set(files) - {checksum_path} + if seen != expected_paths: + raise ValidationError( + "checksum inventory is incomplete; " + f"missing {summarize_paths(expected_paths - seen)}; " + f"extra {summarize_paths(seen - expected_paths)}" + ) + for path, expected in records: + observed = sha256_file(files[path]) + if observed != expected: + raise ValidationError(f"checksum mismatch: {path}") + return tuple(records) + + +def validate_resource( + resource: Any, + *, + label: str, + site: Path, + canonical_base_url: str, + fallback_base_url: str | None, + claimed_paths: set[str], +) -> tuple[str, str, str, tuple[str, ...]]: + """Validate one artifact or manifest and all of its public aliases.""" + + if not isinstance(resource, dict): + raise ValidationError(f"{label} must be an object") + require_known_keys( + resource, + allowed=RESOURCE_KEYS, + required=RESOURCE_KEYS - {"extensions"}, + label=label, + ) + path = relative_path(resource.get("path"), f"{label}.path") + target = site.joinpath(*PurePosixPath(path).parts) + if not target.is_file() or target.stat().st_size <= 0: + raise ValidationError(f"{label} is missing or empty: {path}") + if path in claimed_paths: + raise ValidationError(f"duplicate publication resource path: {path}") + claimed_paths.add(path) + size = resource.get("bytes") + if not isinstance(size, int) or isinstance(size, bool) or size != target.stat().st_size: + raise ValidationError(f"{label}.bytes does not match {path}") + digest = resource.get("sha256") + if not isinstance(digest, str) or not SHA256.fullmatch(digest): + raise ValidationError(f"{label}.sha256 is invalid") + if sha256_file(target) != digest: + raise ValidationError(f"{label}.sha256 does not match {path}") + identifier = require_identifier(resource.get("id"), f"{label}.id") + require_string(resource.get("label"), f"{label}.label") + media_type = require_string(resource.get("media_type"), f"{label}.media_type") + if not MEDIA_TYPE.fullmatch(media_type): + raise ValidationError(f"{label}.media_type is invalid") + if "extensions" in resource: + validate_extensions(resource["extensions"], f"{label}.extensions") + route = relative_path(resource.get("route"), f"{label}.route") + allowed_routes = {path} + if path.endswith("/index.html"): + allowed_routes.add(path[: -len("index.html")]) + if route not in allowed_routes: + raise ValidationError( + f"{label}.route must match its file path or index-page directory" + ) + expected_url = urljoin(canonical_base_url, route) + if resource.get("url") != expected_url: + raise ValidationError(f"{label}.url does not match its canonical path") + expected_fallback = urljoin(fallback_base_url, route) if fallback_base_url else None + if resource.get("fallback_url") != expected_fallback: + raise ValidationError(f"{label}.fallback_url does not match its path") + + aliases = resource.get("aliases", []) + if not isinstance(aliases, list): + raise ValidationError(f"{label}.aliases must be an array") + alias_paths: list[str] = [] + for index, alias in enumerate(aliases): + alias_label = f"{label}.aliases[{index}]" + if not isinstance(alias, dict): + raise ValidationError(f"{alias_label} must be an object") + require_known_keys( + alias, + allowed=ALIAS_KEYS, + required=ALIAS_KEYS, + label=alias_label, + ) + alias_path = relative_path(alias.get("path"), f"{alias_label}.path") + alias_target = site.joinpath(*PurePosixPath(alias_path).parts) + if not alias_target.is_file() or alias_target.stat().st_size <= 0: + raise ValidationError(f"{alias_label} is missing or empty: {alias_path}") + if alias_path in claimed_paths: + raise ValidationError(f"duplicate publication resource path: {alias_path}") + claimed_paths.add(alias_path) + if alias_target.stat().st_size != size or sha256_file(alias_target) != digest: + raise ValidationError( + f"{alias_label} bytes do not match canonical resource {path}" + ) + if alias.get("url") != urljoin(canonical_base_url, alias_path): + raise ValidationError(f"{alias_label}.url does not match its path") + alias_fallback = urljoin(fallback_base_url, alias_path) if fallback_base_url else None + if alias.get("fallback_url") != alias_fallback: + raise ValidationError(f"{alias_label}.fallback_url does not match its path") + alias_paths.append(alias_path) + return identifier, path, route, tuple(alias_paths) + + +def validate_catalog( + catalog: dict[str, Any], + *, + site: Path, + expected_base_url: str, + expected_source_revision: str, + required_routes: tuple[str, ...], + catalog_path: str, + checksum_path: str, +) -> tuple[str, str | None, str, tuple[str, ...], tuple[tuple[str, str], ...]]: + """Validate Beacon's host-neutral publication-site catalog projection.""" + + validate_public_schema(catalog) + require_known_keys( + catalog, + allowed=CATALOG_KEYS, + required=CATALOG_KEYS - {"extensions"}, + label="site catalog", + ) + if ( + catalog.get("schema") != CATALOG_SCHEMA + or catalog.get("schema_version") != "1.0.0" + ): + raise ValidationError(f"site catalog must use {CATALOG_SCHEMA}") + if "extensions" in catalog: + validate_extensions(catalog["extensions"], "site catalog.extensions") + source = catalog.get("source") + if not isinstance(source, dict): + raise ValidationError("site catalog source must be an object") + require_known_keys( + source, + allowed=SOURCE_KEYS, + required={"catalog_sha256", "revision"}, + label="site catalog source", + ) + revision = source.get("revision") + if revision != expected_source_revision or not isinstance(revision, str): + raise ValidationError("site catalog source revision does not match the request") + source_catalog_digest = source.get("catalog_sha256") + if not isinstance(source_catalog_digest, str) or not SHA256.fullmatch( + source_catalog_digest + ): + raise ValidationError("site catalog source.catalog_sha256 is invalid") + if "repository" in source: + validate_https_url(source["repository"], "site catalog source.repository") + + site_record = catalog.get("site") + if not isinstance(site_record, dict): + raise ValidationError("site catalog site must be an object") + require_known_keys( + site_record, + allowed=SITE_KEYS, + required=SITE_KEYS - {"repository", "extensions"}, + label="site catalog site", + ) + require_identifier(site_record.get("id"), "site.id") + for field in ("title", "description", "language", "publisher", "theme"): + require_string(site_record.get(field), f"site.{field}") + if not re.fullmatch( + r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", site_record["language"] + ): + raise ValidationError("site.language is invalid") + if site_record.get("theme") not in {"neutral", "egohygiene"}: + raise ValidationError("site.theme is invalid") + if "repository" in site_record: + validate_https_url(site_record["repository"], "site.repository") + if "extensions" in site_record: + validate_extensions(site_record["extensions"], "site.extensions") + canonical = normalize_base_url( + site_record.get("canonical_base_url"), "site.canonical_base_url" + ) + if canonical != expected_base_url: + raise ValidationError("site canonical base URL does not match the request") + fallback_value = site_record.get("fallback_base_url") + fallback = ( + normalize_base_url(fallback_value, "site.fallback_base_url") + if fallback_value is not None + else None + ) + if fallback is not None and fallback == canonical: + raise ValidationError("fallback base URL cannot equal the canonical base URL") + if site_record.get("stage") not in {"draft", "published", "archived"}: + raise ValidationError("site.stage is invalid") + if site_record.get("revision") != revision: + raise ValidationError("site and source revisions do not match") + + routes = catalog.get("routes") + if not isinstance(routes, list) or not routes: + raise ValidationError("site catalog routes must be a non-empty array") + route_paths: list[str] = [] + route_by_path: dict[str, dict[str, Any]] = {} + route_file_by_path: dict[str, str] = {} + routes_by_id: dict[str, list[dict[str, Any]]] = {} + entrypoints: set[str] = set() + for index, route in enumerate(routes): + label = f"routes[{index}]" + if not isinstance(route, dict): + raise ValidationError(f"{label} must be an object") + require_known_keys( + route, + allowed=ROUTE_KEYS, + required=ROUTE_KEYS - {"file"}, + label=label, + ) + path = relative_path(route.get("path"), f"{label}.path", allow_root=True) + route_id = require_string(route.get("id"), f"{label}.id") + if path in route_paths: + raise ValidationError(f"duplicate route path: {path}") + raw_file = route.get("file") + entrypoint = ( + relative_path(raw_file, f"{label}.file") + if raw_file is not None + else route_entrypoint(path) + ) + if entrypoint.endswith("/"): + raise ValidationError(f"{label}.file must identify a file") + if entrypoint in entrypoints: + raise ValidationError(f"colliding route entrypoint: {entrypoint}") + entrypoints.add(entrypoint) + target = site.joinpath(*PurePosixPath(entrypoint).parts) + if not target.is_file() or target.stat().st_size <= 0: + raise ValidationError(f"declared route is missing or empty: {path}") + if route.get("url") != urljoin(canonical, path): + raise ValidationError(f"{label}.url does not match its canonical route") + expected_fallback = urljoin(fallback, path) if fallback else None + if route.get("fallback_url") != expected_fallback: + raise ValidationError(f"{label}.fallback_url does not match its route") + kind = require_string(route.get("kind"), f"{label}.kind") + if kind not in ROUTE_KINDS: + raise ValidationError(f"{label}.kind is unsupported: {kind}") + route_paths.append(path) + route_by_path[path] = route + route_file_by_path[path] = entrypoint + routes_by_id.setdefault(route_id, []).append(route) + + if routes != sorted( + routes, + key=lambda item: (item["path"], item["kind"], item["id"]), + ): + raise ValidationError("site catalog routes must be sorted by path, kind, and id") + + required_contract_routes = { + "": ("hub", "home", "index.html"), + "downloads/": ("downloads", "downloads", "downloads/index.html"), + catalog_path: ("catalog", "site-json", catalog_path), + checksum_path: ("checksum", "sha256sums", checksum_path), + "manifest.webmanifest": ( + "web-manifest", + "web-manifest", + "manifest.webmanifest", + ), + } + for path, (kind, route_id, physical) in required_contract_routes.items(): + route = route_by_path.get(path) + if ( + route is None + or route.get("kind") != kind + or route.get("id") != route_id + or route_file_by_path.get(path) != physical + ): + raise ValidationError( + f"site catalog is missing required contract route: {path or '/'}" + ) + + for route_id, matching in routes_by_id.items(): + kinds = [route.get("kind") for route in matching] + has_alias = "artifact-alias" in kinds + if has_alias and ( + kinds.count("artifact") != 1 + or any(kind not in {"artifact", "artifact-alias"} for kind in kinds) + ): + raise ValidationError(f"duplicate incompatible route id: {route_id}") + if len(matching) > 1 and not has_alias: + raise ValidationError(f"duplicate incompatible route id: {route_id}") + + missing_routes = sorted(set(required_routes) - set(route_paths)) + if missing_routes: + raise ValidationError( + "site catalog is missing required routes: " + f"{summarize_paths(missing_routes)}" + ) + + slots = catalog.get("slots") + if not isinstance(slots, list): + raise ValidationError("site catalog slots must be an array") + slot_ids: set[str] = set() + slot_order: list[tuple[int, str]] = [] + claimed_paths: set[str] = set() + claimed_publication_routes: set[str] = set() + claimed_slot_routes: set[str] = set() + for index, slot in enumerate(slots): + label = f"slots[{index}]" + if not isinstance(slot, dict): + raise ValidationError(f"{label} must be an object") + require_known_keys( + slot, + allowed=SLOT_KEYS, + required={ + "fallback_url", + "id", + "kind", + "landing", + "order", + "route", + "status", + "summary", + "title", + "url", + }, + label=label, + ) + slot_id = require_identifier(slot.get("id"), f"{label}.id") + if slot_id in slot_ids: + raise ValidationError(f"duplicate slot id: {slot_id}") + slot_ids.add(slot_id) + status = slot.get("status") + if status not in SLOT_STATUSES: + raise ValidationError(f"{label}.status is invalid: {status}") + slot_route = relative_path(slot.get("route"), f"{label}.route", allow_root=True) + if not slot_route or not slot_route.endswith("/"): + raise ValidationError(f"{label}.route must be a non-root page route") + if slot_route not in route_paths: + raise ValidationError(f"{label}.route is not declared in routes") + claimed_slot_routes.add(slot_route) + slot_route_record = route_by_path[slot_route] + if ( + slot_route_record.get("kind") != "slot" + or slot_route_record.get("id") != slot_id + or route_file_by_path.get(slot_route) != f"{slot_route}index.html" + ): + raise ValidationError(f"{label}.route is not a compatible slot route") + if slot.get("url") != urljoin(canonical, slot_route): + raise ValidationError(f"{label}.url does not match its route") + expected_fallback = urljoin(fallback, slot_route) if fallback else None + if slot.get("fallback_url") != expected_fallback: + raise ValidationError(f"{label}.fallback_url does not match its route") + require_identifier(slot.get("kind"), f"{label}.kind") + require_string(slot.get("title"), f"{label}.title") + require_string(slot.get("summary"), f"{label}.summary") + for optional_text_field in ( + "version", + "superseded_by", + "withdrawal_notice", + ): + if optional_text_field in slot: + require_string( + slot[optional_text_field], + f"{label}.{optional_text_field}", + ) + if "extensions" in slot: + validate_extensions(slot["extensions"], f"{label}.extensions") + if "source" in slot: + source_record = slot["source"] + if not isinstance(source_record, dict): + raise ValidationError(f"{label}.source must be an object") + require_known_keys( + source_record, + allowed=SLOT_SOURCE_KEYS, + required={"repository", "revision"}, + label=f"{label}.source", + ) + validate_https_url( + source_record.get("repository"), f"{label}.source.repository" + ) + source_revision = require_string( + source_record.get("revision"), f"{label}.source.revision" + ) + if source_revision != "WORKING_TREE" and not REVISION.fullmatch( + source_revision + ): + raise ValidationError(f"{label}.source.revision is invalid") + if "path" in source_record: + relative_path(source_record["path"], f"{label}.source.path") + order = slot.get("order") + if not isinstance(order, int) or isinstance(order, bool): + raise ValidationError(f"{label}.order must be an integer") + slot_order.append((order, slot_id)) + artifacts = slot.get("artifacts", []) + manifests = slot.get("manifests", []) + if not isinstance(artifacts, list) or not isinstance(manifests, list): + raise ValidationError(f"{label} artifacts and manifests must be arrays") + if status == "planned": + dishonest = sorted( + field + for field in PLANNED_PUBLICATION_FIELDS + if field in slot + ) + if dishonest: + raise ValidationError( + f"planned slot {slot_id} declares publication fields: {dishonest}" + ) + if "extensions" in slot: + reject_reserved_extension_keys(slot["extensions"], f"{label}.extensions") + elif status in {"draft", "withdrawn"} and ( + "artifacts" in slot or "manifests" in slot + ): + raise ValidationError(f"{status} slot {slot_id} cannot publish resources") + elif status == "available" and ( + not artifacts + or not isinstance(slot.get("version"), str) + or not slot["version"].strip() + or not isinstance(slot.get("source"), dict) + ): + raise ValidationError( + f"available slot {slot_id} must declare source, version, and artifact" + ) + elif status == "superseded": + require_string(slot.get("superseded_by"), f"{label}.superseded_by") + if status == "withdrawn": + require_string(slot.get("withdrawal_notice"), f"{label}.withdrawal_notice") + if status == "available": + source_record = slot["source"] + if not isinstance(source_record.get("revision"), str) or not REVISION.fullmatch( + source_record["revision"] + ): + raise ValidationError(f"{label}.source.revision must be pinned") + landing = slot.get("landing") + if not isinstance(landing, dict) or landing.get("mode") not in { + "generated", + "resource", + }: + raise ValidationError(f"{label}.landing is invalid") + mode = landing.get("mode") + require_known_keys( + landing, + allowed=LANDING_KEYS, + required={"mode", "resource_id"} if mode == "resource" else {"mode"}, + label=f"{label}.landing", + ) + if mode == "generated" and "resource_id" in landing: + raise ValidationError( + f"{label}.landing.resource_id is only valid for resource mode" + ) + landing_matched = mode == "generated" + slot_resource_ids: set[str] = set() + slot_resources: dict[str, dict[str, Any]] = {} + for family, resources in (("artifacts", artifacts), ("manifests", manifests)): + for resource_index, resource in enumerate(resources): + resource_id, resource_path, resource_route, aliases = validate_resource( + resource, + label=f"{label}.{family}[{resource_index}]", + site=site, + canonical_base_url=canonical, + fallback_base_url=fallback, + claimed_paths=claimed_paths, + ) + if resource_id in slot_resource_ids: + raise ValidationError( + f"{label} repeats resource id {resource_id}" + ) + slot_resource_ids.add(resource_id) + slot_resources[resource_id] = resource + expected_kind = family[:-1] + route = route_by_path.get(resource_route) + expected_id = f"{slot_id}:{resource_id}" + landing_owner = ( + resource_route == slot_route + and landing.get("mode") == "resource" + and landing.get("resource_id") == resource_id + ) + if landing_owner: + landing_matched = True + if aliases: + raise ValidationError( + f"{label}.landing resource cannot declare aliases" + ) + if resource.get("media_type") != "text/html": + raise ValidationError(f"{label}.landing resource must use text/html") + if resource_path != f"{slot_route}index.html": + raise ValidationError( + f"{label}.landing resource must own its slot index" + ) + compatible = route is not None and route_file_by_path.get( + resource_route + ) == resource_path + if landing_owner: + compatible = compatible and route.get("kind") == "slot" and route.get( + "id" + ) == slot_id + else: + compatible = compatible and route.get("kind") == expected_kind and route.get( + "id" + ) == expected_id + if not compatible: + raise ValidationError( + f"{label}.{family}[{resource_index}] has no compatible declared route" + ) + claimed_publication_routes.add(resource_route) + for alias_path in aliases: + if len(PurePosixPath(alias_path).parts) != 1: + raise ValidationError( + f"{label}.{family}[{resource_index}] alias must be at site root" + ) + alias_route = route_by_path.get(alias_path) + if ( + alias_route is None + or alias_route.get("kind") != "artifact-alias" + or alias_route.get("id") != expected_id + or route_file_by_path.get(alias_path) != alias_path + ): + raise ValidationError( + f"{label}.{family}[{resource_index}] alias has no compatible route" + ) + claimed_publication_routes.add(alias_path) + if not landing_matched: + raise ValidationError(f"{label}.landing references an unknown resource") + identifiers = slot.get("identifiers", []) + if not isinstance(identifiers, list): + raise ValidationError(f"{label}.identifiers must be an array") + identifier_values: set[tuple[str, str]] = set() + for identifier_index, publication_identifier in enumerate(identifiers): + identifier_label = f"{label}.identifiers[{identifier_index}]" + if not isinstance(publication_identifier, dict): + raise ValidationError(f"{identifier_label} must be an object") + require_known_keys( + publication_identifier, + allowed=IDENTIFIER_KEYS, + required={"scheme", "value"}, + label=identifier_label, + ) + scheme = require_identifier( + publication_identifier.get("scheme"), f"{identifier_label}.scheme" + ) + value = require_string( + publication_identifier.get("value"), f"{identifier_label}.value" + ) + if (scheme, value) in identifier_values: + raise ValidationError(f"{label}.identifiers contains duplicates") + identifier_values.add((scheme, value)) + if "url" in publication_identifier: + validate_https_url( + publication_identifier["url"], f"{identifier_label}.url" + ) + if "release" in slot: + release = slot["release"] + if not isinstance(release, dict): + raise ValidationError(f"{label}.release must be an object") + require_known_keys( + release, + allowed=RELEASE_KEYS, + required={"url"}, + label=f"{label}.release", + ) + validate_https_url(release.get("url"), f"{label}.release.url") + if "published_at" in release and not re.fullmatch( + r"\d{4}-\d{2}-\d{2}", + require_string(release["published_at"], f"{label}.release.published_at"), + ): + raise ValidationError(f"{label}.release.published_at is invalid") + if "preview" in slot: + validate_public_reference( + slot["preview"], label=f"{label}.preview", resources=slot_resources + ) + provenance = slot.get("provenance", []) + if not isinstance(provenance, list): + raise ValidationError(f"{label}.provenance must be an array") + for reference_index, reference in enumerate(provenance): + validate_public_reference( + reference, + label=f"{label}.provenance[{reference_index}]", + resources=slot_resources, + ) + if "accessibility" in slot: + accessibility = slot["accessibility"] + if not isinstance(accessibility, dict): + raise ValidationError(f"{label}.accessibility must be an object") + require_known_keys( + accessibility, + allowed=ACCESSIBILITY_KEYS, + required={"summary", "features"}, + label=f"{label}.accessibility", + ) + require_string( + accessibility.get("summary"), f"{label}.accessibility.summary" + ) + features = accessibility.get("features") + if ( + not isinstance(features, list) + or not features + or any(not isinstance(feature, str) or not feature.strip() for feature in features) + or len(features) != len(set(features)) + ): + raise ValidationError( + f"{label}.accessibility.features must be non-empty unique strings" + ) + if "conformance" in accessibility: + require_string( + accessibility["conformance"], + f"{label}.accessibility.conformance", + ) + if "report" in accessibility: + validate_public_reference( + accessibility["report"], + label=f"{label}.accessibility.report", + resources=slot_resources, + ) + if slot_order != sorted(slot_order): + raise ValidationError("site catalog slots must be sorted by order and id") + orphan_routes = sorted( + path + for path, route in route_by_path.items() + if route.get("kind") in {"artifact", "manifest", "artifact-alias"} + and path not in claimed_publication_routes + ) + if orphan_routes: + raise ValidationError( + "site catalog has orphan publication routes: " + f"{summarize_paths(orphan_routes)}" + ) + expected_route_paths = ( + set(required_contract_routes) + | claimed_slot_routes + | claimed_publication_routes + ) + unexpected_routes = sorted(set(route_by_path) - expected_route_paths) + if unexpected_routes: + raise ValidationError("site catalog contains undeclared extra routes") + return ( + canonical, + fallback, + revision, + tuple(route_paths), + tuple((path, route_file_by_path[path]) for path in route_paths), + ) + + +def validate_publication_site( + *, + workspace: Path, + site_directory: str, + expected_base_url: str, + expected_source_revision: str, + expected_fallback_base_url: str = "", + required_routes_json: str = "[]", + catalog_path: str = "site.json", + checksum_path: str = "SHA256SUMS", +) -> ValidationResult: + """Validate one caller-built site and return its stable local evidence.""" + + validate_vendored_contract() + canonical = normalize_base_url(expected_base_url, "expected-base-url") + expected_fallback = ( + normalize_base_url( + expected_fallback_base_url, "expected-fallback-base-url" + ) + if expected_fallback_base_url + else None + ) + if not REVISION.fullmatch(expected_source_revision): + raise ValidationError("expected-source-revision must be a full lowercase Git SHA") + catalog_relative = relative_path(catalog_path, "catalog-path") + checksum_relative = relative_path(checksum_path, "checksum-path") + if catalog_relative == checksum_relative: + raise ValidationError("catalog-path and checksum-path must differ") + reserved_contract_paths = { + "downloads/", + "downloads/index.html", + "index.html", + "manifest.webmanifest", + } + if ( + catalog_relative in reserved_contract_paths + or checksum_relative in reserved_contract_paths + ): + raise ValidationError( + "catalog-path and checksum-path cannot replace a core publication route" + ) + required_routes = parse_required_routes(required_routes_json) + site = resolve_site_directory(workspace, site_directory) + files = inventory_files(site) + catalog_file = files.get(catalog_relative) + if catalog_file is None: + raise ValidationError(f"site catalog is missing: {catalog_relative}") + if catalog_file.stat().st_size > MAX_CATALOG_BYTES: + raise ValidationError("site catalog exceeds the metadata size limit") + checksum_file = files.get(checksum_relative) + if checksum_file is None: + raise ValidationError(f"checksum manifest is missing: {checksum_relative}") + if checksum_file.stat().st_size > MAX_CHECKSUM_BYTES: + raise ValidationError("checksum manifest exceeds the metadata size limit") + records = parse_checksums(site, checksum_relative, files) + catalog = load_object(catalog_file, "site catalog") + observed_canonical, fallback, revision, routes, route_files = validate_catalog( + catalog, + site=site, + expected_base_url=canonical, + expected_source_revision=expected_source_revision, + required_routes=required_routes, + catalog_path=catalog_relative, + checksum_path=checksum_relative, + ) + if expected_fallback is not None and fallback != expected_fallback: + raise ValidationError("site fallback base URL does not match the request") + return ValidationResult( + canonical_base_url=observed_canonical, + fallback_base_url=fallback, + source_revision=revision, + site_tree_sha256=sha256_bytes(files[checksum_relative].read_bytes()), + catalog_sha256=sha256_file(catalog_file), + checksum_sha256=sha256_file(files[checksum_relative]), + catalog_path=catalog_relative, + checksum_path=checksum_relative, + routes=routes, + route_files=route_files, + files=records, + file_count=len(files), + total_bytes=sum(path.stat().st_size for path in files.values()), + ) + + +def write_json(path: Path, value: dict[str, Any]) -> None: + """Write deterministic JSON, creating only the requested parent directory.""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def write_github_outputs(path: Path, result: ValidationResult) -> None: + """Append single-line action outputs.""" + + with path.open("a", encoding="utf-8") as output: + output.write(f"canonical-base-url={result.canonical_base_url}\n") + output.write(f"fallback-base-url={result.fallback_base_url or ''}\n") + output.write(f"file-count={result.file_count}\n") + output.write(f"route-count={len(result.routes)}\n") + output.write(f"site-tree-sha256={result.site_tree_sha256}\n") + output.write(f"source-revision={result.source_revision}\n") + output.write(f"total-bytes={result.total_bytes}\n") + + +def parse_arguments(arguments: Iterable[str] | None = None) -> argparse.Namespace: + """Parse the standalone validator CLI.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--workspace", required=True) + parser.add_argument("--site-directory", required=True) + parser.add_argument("--expected-base-url", required=True) + parser.add_argument("--expected-source-revision", required=True) + parser.add_argument("--expected-fallback-base-url", default="") + parser.add_argument("--required-routes", default="[]") + parser.add_argument("--catalog-path", default="site.json") + parser.add_argument("--checksum-path", default="SHA256SUMS") + parser.add_argument("--evidence-output", default="") + parser.add_argument("--github-output", default="") + return parser.parse_args(arguments) + + +def main(arguments: Iterable[str] | None = None) -> int: + """Run local publication validation for the composite action or a developer.""" + + parsed = parse_arguments(arguments) + evidence_path: Path | None = None + try: + workspace = Path(parsed.workspace) + site_relative = relative_path(parsed.site_directory, "site-directory") + site_candidate = workspace.resolve().joinpath( + *PurePosixPath(site_relative).parts + ) + if parsed.evidence_output: + evidence_path = resolve_workspace_output( + workspace, parsed.evidence_output, site=site_candidate + ) + result = validate_publication_site( + workspace=workspace, + site_directory=parsed.site_directory, + expected_base_url=parsed.expected_base_url, + expected_source_revision=parsed.expected_source_revision, + expected_fallback_base_url=parsed.expected_fallback_base_url, + required_routes_json=parsed.required_routes, + catalog_path=parsed.catalog_path, + checksum_path=parsed.checksum_path, + ) + if evidence_path is not None: + write_json(evidence_path, result.evidence()) + if parsed.github_output: + write_github_outputs(Path(parsed.github_output), result) + except ValidationError as error: + if evidence_path is not None: + write_json( + evidence_path, + { + "error": "publication-site-validation-failed", + "reason": "contract-rejected", + "schema": EVIDENCE_SCHEMA, + "stage": "local-validation", + "status": "failed", + }, + ) + print(f"ERROR: {error}", file=sys.stderr) + return 2 + print( + f"Validated {result.file_count} files and {len(result.routes)} routes " + f"for {result.canonical_base_url}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/actions/verify-publication-pages/README.md b/actions/verify-publication-pages/README.md new file mode 100644 index 0000000..253a5e3 --- /dev/null +++ b/actions/verify-publication-pages/README.md @@ -0,0 +1,35 @@ +# Verify Publication Pages + +`verify-publication-pages` compares an HTTPS deployment with the exact static +tree already reviewed in the workflow. It validates the local Beacon catalog +and complete checksum inventory again, then retrieves the remote catalog, +inventory, every checksummed file, and every declared route under a bounded +application-level deadline. + +Redirects may move only between the declared canonical and fallback HTTPS +hosts. Attempts, delay, per-request timeout, and total elapsed time are capped. +The action never logs response bodies or headers and emits sorted JSON evidence +containing the source revision, exact Relay workflow identity, deterministic +site-tree digest, verified targets, routes, and result. + +An empty `fallback-base-url` accepts nullable fallback metadata in the catalog +but does not authorize redirects or remote verification through that host. Set +the input to the exact catalog value before using `verify-fallback: true`; Relay +then pins the fallback as a second allowed endpoint. + +The verifier resolves every hostname and rejects non-global DNS answers before +each HTTPS request, validates every redirect hop, and never automatically +follows a redirect. Python's platform DNS resolver has no per-call timeout, so +the reusable workflow's fixed 20-minute job timeout is the final outer +bound around DNS and the application-level request/retry deadline. + +The `configuration-only` input performs all local contract, public-host, +fallback, workflow-identity, and retry-bound checks without network access. The +reusable workflow uses this gate before granting bytes to GitHub Pages. +Successful deployment, successful predeploy, and sanitized failure evidence use +the versioned +[`publication-pages-evidence.schema.json`](schemas/publication-pages-evidence.schema.json). + +This action is normally called by Relay's `publication-pages.yml` reusable +workflow after `actions/deploy-pages`. Products should not use it to build, +render, mutate, or publish source content. diff --git a/actions/verify-publication-pages/action.yml b/actions/verify-publication-pages/action.yml new file mode 100644 index 0000000..0f3abe8 --- /dev/null +++ b/actions/verify-publication-pages/action.yml @@ -0,0 +1,138 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json + +name: "Verify Publication Pages" +description: >- + Verify deployed HTTPS routes and every checksummed file against an already + validated publication site, with bounded retries and deterministic evidence. + +inputs: + site-directory: + description: "Workspace-relative directory containing the reviewed static site." + required: true + expected-base-url: + description: "Canonical HTTPS base URL expected locally and remotely." + required: true + expected-source-revision: + description: "Full lowercase source commit expected locally and remotely." + required: true + required-routes: + description: "JSON array of routes that must exist locally and remotely." + required: false + default: "[]" + catalog-path: + description: "Site-relative Beacon publication catalog path." + required: false + default: "site.json" + checksum-path: + description: "Site-relative complete SHA-256 inventory path." + required: false + default: "SHA256SUMS" + fallback-base-url: + description: "Explicitly authorized fallback URL; when set it must match the catalog." + required: false + default: "" + verify-fallback: + description: "Verify all bytes at the explicitly authorized fallback target." + required: false + default: "false" + deployment-url: + description: "URL returned by deploy-pages; when set it must be canonical." + required: false + default: "" + configuration-only: + description: "Validate all predeploy inputs without making network requests." + required: false + default: "false" + attempts: + description: "Maximum attempts per remote resource, from 1 through 12." + required: false + default: "8" + retry-delay-seconds: + description: "Delay between attempts, from 0 through 30 seconds." + required: false + default: "5" + request-timeout-seconds: + description: "Per-request timeout, from 1 through 60 seconds." + required: false + default: "20" + max-elapsed-seconds: + description: "Total verification deadline, from 1 through 600 seconds." + required: false + default: "300" + workflow-ref: + description: "Exact called Relay workflow reference recorded in evidence." + required: true + workflow-sha: + description: "Exact called Relay workflow commit recorded in evidence." + required: true + evidence-output: + description: "Workspace-relative deployment evidence destination." + required: false + default: ".relay/publication-pages-evidence.json" + +outputs: + file-count: + description: "Number of files in the verified complete site." + value: "${{ steps.verify.outputs.file-count }}" + site-tree-sha256: + description: "SHA-256 digest of the verified complete checksum inventory." + value: "${{ steps.verify.outputs.site-tree-sha256 }}" + target-count: + description: "Number of verified canonical/fallback targets." + value: "${{ steps.verify.outputs.target-count }}" + total-bytes: + description: "Total bytes in the verified complete site." + value: "${{ steps.verify.outputs.total-bytes }}" + +branding: + icon: "globe" + color: "blue" + +runs: + using: "composite" + steps: + - name: Verify deployed publication pages + id: verify + shell: bash + env: + INPUT_ATTEMPTS: "${{ inputs.attempts }}" + INPUT_CATALOG_PATH: "${{ inputs.catalog-path }}" + INPUT_CHECKSUM_PATH: "${{ inputs.checksum-path }}" + INPUT_CONFIGURATION_ONLY: "${{ inputs.configuration-only }}" + INPUT_DEPLOYMENT_URL: "${{ inputs.deployment-url }}" + INPUT_EVIDENCE_OUTPUT: "${{ inputs.evidence-output }}" + INPUT_EXPECTED_BASE_URL: "${{ inputs.expected-base-url }}" + INPUT_EXPECTED_SOURCE_REVISION: "${{ inputs.expected-source-revision }}" + INPUT_FALLBACK_BASE_URL: "${{ inputs.fallback-base-url }}" + INPUT_MAX_ELAPSED_SECONDS: "${{ inputs.max-elapsed-seconds }}" + INPUT_REQUEST_TIMEOUT_SECONDS: "${{ inputs.request-timeout-seconds }}" + INPUT_REQUIRED_ROUTES: "${{ inputs.required-routes }}" + INPUT_RETRY_DELAY_SECONDS: "${{ inputs.retry-delay-seconds }}" + INPUT_SITE_DIRECTORY: "${{ inputs.site-directory }}" + INPUT_VERIFY_FALLBACK: "${{ inputs.verify-fallback }}" + INPUT_WORKFLOW_REF: "${{ inputs.workflow-ref }}" + INPUT_WORKFLOW_SHA: "${{ inputs.workflow-sha }}" + run: | + set -euo pipefail + python3 "${GITHUB_ACTION_PATH}/scripts/verify_publication_pages.py" \ + --workspace "${GITHUB_WORKSPACE}" \ + --site-directory "${INPUT_SITE_DIRECTORY}" \ + --expected-base-url "${INPUT_EXPECTED_BASE_URL}" \ + --expected-source-revision "${INPUT_EXPECTED_SOURCE_REVISION}" \ + --required-routes "${INPUT_REQUIRED_ROUTES}" \ + --catalog-path "${INPUT_CATALOG_PATH}" \ + --checksum-path "${INPUT_CHECKSUM_PATH}" \ + --configuration-only "${INPUT_CONFIGURATION_ONLY}" \ + --fallback-base-url "${INPUT_FALLBACK_BASE_URL}" \ + --verify-fallback "${INPUT_VERIFY_FALLBACK}" \ + --deployment-url "${INPUT_DEPLOYMENT_URL}" \ + --attempts "${INPUT_ATTEMPTS}" \ + --retry-delay-seconds "${INPUT_RETRY_DELAY_SECONDS}" \ + --request-timeout-seconds "${INPUT_REQUEST_TIMEOUT_SECONDS}" \ + --max-elapsed-seconds "${INPUT_MAX_ELAPSED_SECONDS}" \ + --workflow-ref "${INPUT_WORKFLOW_REF}" \ + --workflow-sha "${INPUT_WORKFLOW_SHA}" \ + --evidence-output "${INPUT_EVIDENCE_OUTPUT}" \ + --github-output "${GITHUB_OUTPUT}" diff --git a/actions/verify-publication-pages/schemas/publication-pages-evidence.schema.json b/actions/verify-publication-pages/schemas/publication-pages-evidence.schema.json new file mode 100644 index 0000000..e6924ae --- /dev/null +++ b/actions/verify-publication-pages/schemas/publication-pages-evidence.schema.json @@ -0,0 +1,154 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/relay/contracts/publication-pages-evidence/v1/schema.json", + "title": "Relay publication Pages evidence v1", + "oneOf": [ + {"$ref": "#/$defs/deployment"}, + {"$ref": "#/$defs/predeploy"}, + {"$ref": "#/$defs/failure"} + ], + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "revision": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "workflow": { + "type": "object", + "additionalProperties": false, + "required": ["ref", "repository", "sha"], + "properties": { + "ref": {"type": "string", "minLength": 1}, + "repository": {"const": "egohygiene/relay"}, + "sha": {"$ref": "#/$defs/revision"} + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": [ + "base_url", + "catalog_sha256", + "checksum_sha256", + "file_count", + "route_count", + "routes", + "status", + "target" + ], + "properties": { + "base_url": {"type": "string", "format": "uri"}, + "catalog_sha256": {"$ref": "#/$defs/sha256"}, + "checksum_sha256": {"$ref": "#/$defs/sha256"}, + "file_count": {"type": "integer", "minimum": 1}, + "route_count": {"type": "integer", "minimum": 1}, + "routes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": {"type": "string"}, + "sha256": {"$ref": "#/$defs/sha256"} + } + } + }, + "status": {"const": "passed"}, + "target": {"enum": ["canonical", "fallback"]} + } + }, + "deployment": { + "type": "object", + "additionalProperties": false, + "required": [ + "file_count", + "result", + "schema", + "site_tree_sha256", + "source_revision", + "targets", + "total_bytes", + "workflow" + ], + "properties": { + "file_count": {"type": "integer", "minimum": 1}, + "result": {"const": "passed"}, + "schema": {"const": "egohygiene.relay.publication-pages-evidence/v1"}, + "site_tree_sha256": {"$ref": "#/$defs/sha256"}, + "source_revision": {"$ref": "#/$defs/revision"}, + "targets": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": {"$ref": "#/$defs/target"} + }, + "total_bytes": {"type": "integer", "minimum": 1}, + "workflow": {"$ref": "#/$defs/workflow"} + } + }, + "predeploy": { + "type": "object", + "additionalProperties": false, + "required": [ + "canonical_base_url", + "fallback_base_url", + "file_count", + "result", + "schema", + "site_tree_sha256", + "source_revision", + "stage", + "targets", + "total_bytes", + "workflow" + ], + "properties": { + "canonical_base_url": {"type": "string", "format": "uri"}, + "fallback_base_url": { + "oneOf": [ + {"type": "null"}, + {"type": "string", "format": "uri"} + ] + }, + "file_count": {"type": "integer", "minimum": 1}, + "result": {"const": "passed"}, + "schema": {"const": "egohygiene.relay.publication-pages-evidence/v1"}, + "site_tree_sha256": {"$ref": "#/$defs/sha256"}, + "source_revision": {"$ref": "#/$defs/revision"}, + "stage": {"const": "predeploy-configuration"}, + "targets": {"const": []}, + "total_bytes": {"type": "integer", "minimum": 1}, + "workflow": {"$ref": "#/$defs/workflow"} + } + }, + "failure": { + "type": "object", + "additionalProperties": false, + "required": ["error", "reason", "result", "schema", "stage", "workflow"], + "properties": { + "error": {"const": "publication-pages-verification-failed"}, + "reason": { + "enum": ["configuration-rejected", "remote-proof-failed"] + }, + "result": {"const": "failed"}, + "schema": {"const": "egohygiene.relay.publication-pages-evidence/v1"}, + "stage": { + "enum": ["input-validation", "predeploy-configuration", "remote-verification"] + }, + "workflow": { + "type": "object", + "additionalProperties": false, + "required": ["identity", "repository"], + "properties": { + "identity": {"const": "unverified"}, + "repository": {"const": "egohygiene/relay"} + } + } + } + } + } +} diff --git a/actions/verify-publication-pages/scripts/verify_publication_pages.py b/actions/verify-publication-pages/scripts/verify_publication_pages.py new file mode 100644 index 0000000..ce1c503 --- /dev/null +++ b/actions/verify-publication-pages/scripts/verify_publication_pages.py @@ -0,0 +1,718 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT + +"""Verify deployed publication bytes and write bounded deployment evidence.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +import importlib.util +import ipaddress +import json +from pathlib import Path, PurePosixPath +import re +import socket +import sys +import time +from typing import Any, Callable, Iterable +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin, urlsplit, urlunsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +ACTION_ROOT = Path(__file__).resolve().parents[2] +VALIDATOR_PATH = ( + ACTION_ROOT + / "validate-publication-site" + / "scripts" + / "validate_publication_site.py" +) +SPEC = importlib.util.spec_from_file_location("relay_publication_site", VALIDATOR_PATH) +if SPEC is None or SPEC.loader is None: # pragma: no cover - installation corruption + raise RuntimeError(f"cannot load Relay publication validator: {VALIDATOR_PATH}") +site_contract = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = site_contract +SPEC.loader.exec_module(site_contract) + + +EVIDENCE_SCHEMA = "egohygiene.relay.publication-pages-evidence/v1" +FULL_SHA = re.compile(r"^[0-9a-f]{40}$") +MAX_ATTEMPTS = 12 +MAX_DELAY_SECONDS = 30.0 +MAX_REQUEST_TIMEOUT_SECONDS = 60.0 +MAX_ELAPSED_SECONDS = 600.0 +MAX_REDIRECTS = 5 +PUBLIC_HOST = re.compile( + r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$" +) + + +class VerificationError(ValueError): + """Remote deployment evidence does not match the validated local site.""" + + +@dataclass(frozen=True) +class FetchResult: + """Safe result of one HTTPS fetch.""" + + body: bytes + final_url: str + status: int + redirect_url: str | None = None + + +@dataclass(frozen=True) +class RetryPolicy: + """Bounded network policy shared by every deployment fetch.""" + + attempts: int + delay_seconds: float + request_timeout_seconds: float + max_elapsed_seconds: float + + def validate(self) -> None: + """Reject values that could create an unbounded Actions job.""" + + if not 1 <= self.attempts <= MAX_ATTEMPTS: + raise VerificationError(f"attempts must be between 1 and {MAX_ATTEMPTS}") + if not 0 <= self.delay_seconds <= MAX_DELAY_SECONDS: + raise VerificationError( + f"retry-delay-seconds must be between 0 and {MAX_DELAY_SECONDS:g}" + ) + if not 1 <= self.request_timeout_seconds <= MAX_REQUEST_TIMEOUT_SECONDS: + raise VerificationError( + "request-timeout-seconds must be between 1 and " + f"{MAX_REQUEST_TIMEOUT_SECONDS:g}" + ) + if not 1 <= self.max_elapsed_seconds <= MAX_ELAPSED_SECONDS: + raise VerificationError( + f"max-elapsed-seconds must be between 1 and {MAX_ELAPSED_SECONDS:g}" + ) + + +def parse_boolean(value: str, label: str) -> bool: + """Parse an explicit lowercase workflow boolean.""" + + if value == "true": + return True + if value == "false": + return False + raise VerificationError(f"{label} must be true or false") + + +def validate_workflow_identity(workflow_ref: str, workflow_sha: str) -> None: + """Require immutable evidence about the called Relay workflow.""" + + if not FULL_SHA.fullmatch(workflow_sha): + raise VerificationError("workflow-sha must be a full lowercase Git SHA") + expected = ( + "egohygiene/relay/.github/workflows/publication-pages.yml@" + workflow_sha + ) + if workflow_ref != expected: + raise VerificationError("workflow-ref is not the Relay publication workflow") + + +def validate_public_base_url(value: str, label: str) -> str: + """Require a standard-port, public-looking DNS HTTPS base.""" + + normalized = site_contract.normalize_base_url(value, label) + parsed = urlsplit(normalized) + if parsed.port is not None: + raise VerificationError(f"{label} cannot use a nonstandard port") + hostname = parsed.hostname or "" + try: + ipaddress.ip_address(hostname) + except ValueError: + pass + else: + raise VerificationError(f"{label} cannot use an IP literal") + if ( + not PUBLIC_HOST.fullmatch(hostname) + or hostname == "localhost" + or hostname.endswith((".localhost", ".local", ".internal", ".home", ".lan")) + ): + raise VerificationError(f"{label} must use a public-looking DNS host") + return normalized + + +def assert_public_resolution(url: str) -> None: + """Reject current DNS answers containing local, private, or reserved addresses.""" + + hostname = urlsplit(url).hostname + if hostname is None: + raise VerificationError("remote URL has no hostname") + addresses = { + record[4][0] + for record in socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM) + } + if not addresses: + raise VerificationError("remote DNS lookup returned no addresses") + for address in addresses: + if not ipaddress.ip_address(address).is_global: + raise VerificationError("remote DNS resolved to a non-public address") + + +def cache_busted_url(base_url: str, relative: str, revision: str) -> str: + """Return one exact route URL with a safe deployment-revision cache key.""" + + if not FULL_SHA.fullmatch(revision): + raise VerificationError("cache revision must be a full lowercase Git SHA") + target = urlsplit(urljoin(base_url, relative)) + if target.query or target.fragment: + raise VerificationError("publication routes cannot contain query or fragment") + return urlunsplit( + (target.scheme, target.netloc, target.path, f"relay_revision={revision}", "") + ) + + +class _NoRedirect(HTTPRedirectHandler): + """Expose redirects to Relay instead of allowing urllib to follow them.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001 + return None + + +def default_fetch( + url: str, + timeout: float, + max_bytes: int, + deadline: float, +) -> FetchResult: + """Fetch one public HTTPS resource with no automatic redirects or unbounded read.""" + + assert_public_resolution(url) + request = Request( + url, + headers={ + "Accept": "*/*", + "Accept-Encoding": "identity", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "User-Agent": "egohygiene-relay-publication-verifier/1", + }, + method="GET", + ) + opener = build_opener(_NoRedirect()) + try: + response = opener.open(request, timeout=timeout) # noqa: S310 - URL checked + except HTTPError as error: + if error.code not in {301, 302, 303, 307, 308}: + raise + location = error.headers.get("Location") + if not location: + raise VerificationError("remote redirect omitted Location") from error + return FetchResult( + body=b"", + final_url=url, + status=error.code, + redirect_url=urljoin(url, location), + ) + with response: + content_encoding = ( + response.headers.get("Content-Encoding") or "identity" + ).strip().lower() + if content_encoding != "identity": + raise VerificationError("remote response used an unsupported content encoding") + body = bytearray() + while True: + if time.monotonic() >= deadline: + raise TimeoutError("remote response exceeded total deadline") + reader = getattr(response, "read1", response.read) + chunk = reader(min(64 * 1024, max_bytes + 1 - len(body))) + if not chunk: + break + body.extend(chunk) + if time.monotonic() >= deadline: + raise TimeoutError("remote response exceeded total deadline") + if len(body) > max_bytes: + raise VerificationError("remote response exceeds reviewed byte count") + return FetchResult( + body=bytes(body), + final_url=url, + status=int(response.status), + ) + + +def fetch_with_retry( + base_url: str, + relative: str, + *, + revision: str, + expected_bytes: int, + expected_body: bytes | None = None, + expected_sha256: str | None = None, + policy: RetryPolicy, + deadline: float, + declared_bases: tuple[str, ...], + fetch: Callable[[str, float, int, float], FetchResult] = default_fetch, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, +) -> FetchResult: + """Fetch a resource under attempt, timeout, redirect, and total-time caps.""" + + allowed_urls = { + cache_busted_url(declared, relative, revision) for declared in declared_bases + } + url = cache_busted_url(base_url, relative, revision) + if url not in allowed_urls: + raise VerificationError("remote verification accepts only declared HTTPS routes") + last_error = "request did not run" + for attempt in range(1, policy.attempts + 1): + remaining = deadline - clock() + if remaining <= 0: + break + timeout = min(policy.request_timeout_seconds, max(1.0, remaining)) + try: + current = url + for redirect_count in range(MAX_REDIRECTS + 1): + remaining = deadline - clock() + if remaining <= 0: + raise TimeoutError("remote request exceeded total deadline") + timeout = min(policy.request_timeout_seconds, max(1.0, remaining)) + if current not in allowed_urls: + raise VerificationError( + "remote request redirected outside the exact declared route" + ) + result = fetch(current, timeout, expected_bytes, deadline) + if result.redirect_url is None: + if result.final_url != current: + raise VerificationError("transport followed an unvalidated redirect") + if result.status != 200: + raise VerificationError( + f"remote request returned HTTP {result.status}" + ) + if len(result.body) > expected_bytes: + raise VerificationError( + "remote response exceeds reviewed byte count" + ) + if expected_body is not None and result.body != expected_body: + raise VerificationError( + "remote response does not match reviewed bytes" + ) + if ( + expected_sha256 is not None + and hashlib.sha256(result.body).hexdigest() != expected_sha256 + ): + raise VerificationError( + "remote response does not match reviewed digest" + ) + return result + if redirect_count == MAX_REDIRECTS: + raise VerificationError("remote request exceeded redirect limit") + current = result.redirect_url + raise VerificationError("remote request exceeded redirect limit") + except (HTTPError, URLError, TimeoutError, OSError, VerificationError) as error: + last_error = type(error).__name__ + if attempt == policy.attempts: + break + remaining = deadline - clock() + if remaining <= 0: + break + sleep(min(policy.delay_seconds, remaining)) + raise VerificationError( + f"remote resource was unavailable after bounded retries ({last_error})" + ) + + +def verify_remote_target( + *, + target_kind: str, + base_url: str, + local_site: Path, + validation: Any, + revision: str, + policy: RetryPolicy, + deadline: float, + declared_bases: tuple[str, ...], + fetch: Callable[[str, float, int, float], FetchResult] = default_fetch, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, +) -> dict[str, Any]: + """Verify the catalog, inventory, all bytes, and every declared route.""" + + catalog_local = (local_site / validation.catalog_path).read_bytes() + checksum_local = (local_site / validation.checksum_path).read_bytes() + catalog_remote = fetch_with_retry( + base_url, + validation.catalog_path, + revision=revision, + expected_bytes=len(catalog_local), + expected_body=catalog_local, + policy=policy, + deadline=deadline, + declared_bases=declared_bases, + fetch=fetch, + sleep=sleep, + clock=clock, + ) + if catalog_remote.body != catalog_local: + raise VerificationError(f"{target_kind} catalog bytes do not match the reviewed site") + checksum_remote = fetch_with_retry( + base_url, + validation.checksum_path, + revision=revision, + expected_bytes=len(checksum_local), + expected_body=checksum_local, + policy=policy, + deadline=deadline, + declared_bases=declared_bases, + fetch=fetch, + sleep=sleep, + clock=clock, + ) + if checksum_remote.body != checksum_local: + raise VerificationError( + f"{target_kind} checksum inventory does not match the reviewed site" + ) + + verified_files: list[dict[str, str]] = [] + for relative, expected_digest in validation.files: + local_size = (local_site / PurePosixPath(relative)).stat().st_size + result = fetch_with_retry( + base_url, + relative, + revision=revision, + expected_bytes=local_size, + expected_sha256=expected_digest, + policy=policy, + deadline=deadline, + declared_bases=declared_bases, + fetch=fetch, + sleep=sleep, + clock=clock, + ) + observed = hashlib.sha256(result.body).hexdigest() + if observed != expected_digest: + raise VerificationError( + f"{target_kind} deployed digest does not match: {relative}" + ) + verified_files.append({"path": relative, "sha256": observed}) + + verified_routes: list[dict[str, str]] = [] + for route, entrypoint in validation.route_files: + expected = (local_site / PurePosixPath(entrypoint)).read_bytes() + result = fetch_with_retry( + base_url, + route, + revision=revision, + expected_bytes=len(expected), + expected_body=expected, + policy=policy, + deadline=deadline, + declared_bases=declared_bases, + fetch=fetch, + sleep=sleep, + clock=clock, + ) + if result.body != expected: + raise VerificationError( + f"{target_kind} route bytes do not match: {route or '/'}" + ) + verified_routes.append( + { + "path": route, + "sha256": hashlib.sha256(result.body).hexdigest(), + } + ) + return { + "base_url": base_url, + "catalog_sha256": hashlib.sha256(catalog_remote.body).hexdigest(), + "checksum_sha256": hashlib.sha256(checksum_remote.body).hexdigest(), + "file_count": len(verified_files), + "route_count": len(verified_routes), + "routes": verified_routes, + "status": "passed", + "target": target_kind, + } + + +def verification_evidence( + *, + validation: Any, + workflow_ref: str, + workflow_sha: str, + targets: list[dict[str, Any]], +) -> dict[str, Any]: + """Return deterministic successful deployment evidence.""" + + return { + "file_count": validation.file_count, + "result": "passed", + "schema": EVIDENCE_SCHEMA, + "site_tree_sha256": validation.site_tree_sha256, + "source_revision": validation.source_revision, + "targets": targets, + "total_bytes": validation.total_bytes, + "workflow": { + "ref": workflow_ref, + "repository": "egohygiene/relay", + "sha": workflow_sha, + }, + } + + +def validate_verification_configuration( + *, + workspace: Path, + site_directory: str, + expected_base_url: str, + expected_source_revision: str, + required_routes_json: str, + catalog_path: str, + checksum_path: str, + fallback_base_url: str, + verify_fallback: bool, + deployment_url: str, + workflow_ref: str, + workflow_sha: str, + policy: RetryPolicy, +) -> tuple[Any, str, str | None]: + """Validate every local, endpoint, identity, and retry input without networking.""" + + policy.validate() + validate_workflow_identity(workflow_ref, workflow_sha) + validation = site_contract.validate_publication_site( + workspace=workspace, + site_directory=site_directory, + expected_base_url=expected_base_url, + expected_source_revision=expected_source_revision, + required_routes_json=required_routes_json, + catalog_path=catalog_path, + checksum_path=checksum_path, + ) + canonical = validate_public_base_url( + validation.canonical_base_url, "canonical-base-url" + ) + if deployment_url: + observed_deployment = validate_public_base_url( + deployment_url, "deployment-url" + ) + if observed_deployment != canonical: + raise VerificationError( + "GitHub Pages deployment URL does not match the canonical catalog URL" + ) + catalog_fallback = ( + validate_public_base_url(validation.fallback_base_url, "catalog fallback-base-url") + if validation.fallback_base_url is not None + else None + ) + authorized_fallback: str | None = None + if fallback_base_url: + authorized_fallback = validate_public_base_url( + fallback_base_url, "fallback-base-url" + ) + if authorized_fallback != catalog_fallback: + raise VerificationError("fallback-base-url does not match the site catalog") + if verify_fallback and authorized_fallback is None: + raise VerificationError( + "fallback verification requires an explicit matching fallback-base-url" + ) + return validation, canonical, authorized_fallback + + +def verify_publication_pages( + *, + workspace: Path, + site_directory: str, + expected_base_url: str, + expected_source_revision: str, + required_routes_json: str, + catalog_path: str, + checksum_path: str, + fallback_base_url: str, + verify_fallback: bool, + deployment_url: str, + workflow_ref: str, + workflow_sha: str, + policy: RetryPolicy, + fetch: Callable[[str, float, int, float], FetchResult] = default_fetch, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, +) -> dict[str, Any]: + """Validate the local site, then prove canonical and optional fallback bytes.""" + + validation, canonical, fallback = validate_verification_configuration( + workspace=workspace, + site_directory=site_directory, + expected_base_url=expected_base_url, + expected_source_revision=expected_source_revision, + required_routes_json=required_routes_json, + catalog_path=catalog_path, + checksum_path=checksum_path, + fallback_base_url=fallback_base_url, + verify_fallback=verify_fallback, + deployment_url=deployment_url, + workflow_ref=workflow_ref, + workflow_sha=workflow_sha, + policy=policy, + ) + + declared_bases = tuple(value for value in (canonical, fallback) if value is not None) + deadline = clock() + policy.max_elapsed_seconds + local_site = site_contract.resolve_site_directory(workspace, site_directory) + targets = [ + verify_remote_target( + target_kind="canonical", + base_url=canonical, + local_site=local_site, + validation=validation, + revision=expected_source_revision, + policy=policy, + deadline=deadline, + declared_bases=declared_bases, + fetch=fetch, + sleep=sleep, + clock=clock, + ) + ] + if verify_fallback and fallback is not None: + targets.append( + verify_remote_target( + target_kind="fallback", + base_url=fallback, + local_site=local_site, + validation=validation, + revision=expected_source_revision, + policy=policy, + deadline=deadline, + declared_bases=declared_bases, + fetch=fetch, + sleep=sleep, + clock=clock, + ) + ) + return verification_evidence( + validation=validation, + workflow_ref=workflow_ref, + workflow_sha=workflow_sha, + targets=targets, + ) + + +def parse_arguments(arguments: Iterable[str] | None = None) -> argparse.Namespace: + """Parse composite-action inputs.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--workspace", required=True) + parser.add_argument("--site-directory", required=True) + parser.add_argument("--expected-base-url", required=True) + parser.add_argument("--expected-source-revision", required=True) + parser.add_argument("--required-routes", default="[]") + parser.add_argument("--catalog-path", default="site.json") + parser.add_argument("--checksum-path", default="SHA256SUMS") + parser.add_argument("--fallback-base-url", default="") + parser.add_argument("--verify-fallback", default="false") + parser.add_argument("--deployment-url", default="") + parser.add_argument("--configuration-only", default="false") + parser.add_argument("--attempts", type=int, default=8) + parser.add_argument("--retry-delay-seconds", type=float, default=5) + parser.add_argument("--request-timeout-seconds", type=float, default=20) + parser.add_argument("--max-elapsed-seconds", type=float, default=300) + parser.add_argument("--workflow-ref", required=True) + parser.add_argument("--workflow-sha", required=True) + parser.add_argument("--evidence-output", required=True) + parser.add_argument("--github-output", default="") + return parser.parse_args(arguments) + + +def main(arguments: Iterable[str] | None = None) -> int: + """Verify a deployed publication site and emit stable evidence.""" + + parsed = parse_arguments(arguments) + evidence_path: Path | None = None + failure_stage = "input-validation" + try: + workspace = Path(parsed.workspace) + site = site_contract.resolve_site_directory(workspace, parsed.site_directory) + evidence_path = site_contract.resolve_workspace_output( + workspace, parsed.evidence_output, site=site + ) + verify_fallback = parse_boolean(parsed.verify_fallback, "verify-fallback") + configuration_only = parse_boolean( + parsed.configuration_only, "configuration-only" + ) + failure_stage = ( + "predeploy-configuration" if configuration_only else "remote-verification" + ) + policy = RetryPolicy( + attempts=parsed.attempts, + delay_seconds=parsed.retry_delay_seconds, + request_timeout_seconds=parsed.request_timeout_seconds, + max_elapsed_seconds=parsed.max_elapsed_seconds, + ) + common = { + "workspace": workspace, + "site_directory": parsed.site_directory, + "expected_base_url": parsed.expected_base_url, + "expected_source_revision": parsed.expected_source_revision, + "required_routes_json": parsed.required_routes, + "catalog_path": parsed.catalog_path, + "checksum_path": parsed.checksum_path, + "fallback_base_url": parsed.fallback_base_url, + "verify_fallback": verify_fallback, + "deployment_url": parsed.deployment_url, + "workflow_ref": parsed.workflow_ref, + "workflow_sha": parsed.workflow_sha, + "policy": policy, + } + if configuration_only: + validation, canonical, fallback = validate_verification_configuration( + **common + ) + evidence = { + "canonical_base_url": canonical, + "fallback_base_url": fallback, + "file_count": validation.file_count, + "result": "passed", + "schema": EVIDENCE_SCHEMA, + "site_tree_sha256": validation.site_tree_sha256, + "source_revision": validation.source_revision, + "stage": "predeploy-configuration", + "targets": [], + "total_bytes": validation.total_bytes, + "workflow": { + "ref": parsed.workflow_ref, + "repository": "egohygiene/relay", + "sha": parsed.workflow_sha, + }, + } + else: + evidence = verify_publication_pages(**common) + site_contract.write_json(evidence_path, evidence) + if parsed.github_output: + with Path(parsed.github_output).open("a", encoding="utf-8") as output: + output.write(f"site-tree-sha256={evidence['site_tree_sha256']}\n") + output.write(f"target-count={len(evidence['targets'])}\n") + output.write(f"file-count={evidence['file_count']}\n") + output.write(f"total-bytes={evidence['total_bytes']}\n") + except (site_contract.ValidationError, VerificationError) as error: + failure = { + "error": "publication-pages-verification-failed", + "reason": ( + "configuration-rejected" + if failure_stage != "remote-verification" + else "remote-proof-failed" + ), + "result": "failed", + "schema": EVIDENCE_SCHEMA, + "stage": failure_stage, + "workflow": { + "identity": "unverified", + "repository": "egohygiene/relay", + }, + } + if evidence_path is not None: + site_contract.write_json(evidence_path, failure) + print(f"ERROR: {error}", file=sys.stderr) + return 2 + print( + f"Verified {len(evidence['targets'])} publication target(s) for " + f"{evidence['source_revision']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/workflows/README.md b/examples/workflows/README.md index 0e6536a..cabbbab 100644 --- a/examples/workflows/README.md +++ b/examples/workflows/README.md @@ -13,3 +13,26 @@ The reusable workflow owns checkout, generation, provenance verification, and ordinary artifact upload. It does not deploy Pages, write repository content, or receive caller secrets. A consumer that needs site composition should use the composite action in its existing build job instead. + +## Publication Pages lifecycle + +The publication examples deliberately use two statically permissioned caller +jobs after one product-owned producer: + +- a pull-request call to `publication-review.yml` grants only `actions: read` + and `contents: read`; +- a mutually exclusive default-branch call to deployment-only + `publication-pages.yml` additionally grants only `pages: write` and + `id-token: write`. + +Relay never checks out or builds the product. It downloads the ordinary static +artifact uploaded by the producer, validates it, and makes the exact reviewed +bytes the deployment boundary. Use the repository-specific migration guides: + +- [Antidote publication Pages](publication-pages-antidote.md) +- [Reflector publication Pages](publication-pages-reflector.md) + +The `` marker must be replaced with the reviewed +v1.3.0 release commit after publication. Keep the product's prior workflow as a +rollback reference until its canonical and optional fallback endpoints pass the +remote byte proof. Refs #38. diff --git a/examples/workflows/publication-pages-antidote.md b/examples/workflows/publication-pages-antidote.md new file mode 100644 index 0000000..dc63f16 --- /dev/null +++ b/examples/workflows/publication-pages-antidote.md @@ -0,0 +1,92 @@ +# Antidote publication Pages caller + +This is the target two-job authority pattern for Antidote after Relay v1.3.0 is +released. Keep Antidote's existing pinned dependency-install and native +`task check-site` steps in the producer job; replace only the duplicated Pages +review/deploy/remote-proof implementation with the pinned Relay call. + +```yaml +name: Antidote publication hub + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build_site: + name: Build product-owned publication site + permissions: + contents: read + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout Antidote + uses: actions/checkout@ + with: + persist-credentials: false + + # Preserve Antidote's existing pinned Task/LaTeX/Pandoc installation. + - name: Build and validate Antidote publication site + run: task check-site THEME="egohygiene" BUILD_DIR="build/egohygiene" + + - name: Upload caller-built static site + uses: actions/upload-artifact@ + with: + name: "antidote-publication-site-${{ github.sha }}" + path: "_site" + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + review_site: + name: Review publication bytes + if: "${{ github.event_name == 'pull_request' }}" + needs: build_site + permissions: + actions: read + contents: read + # egohygiene/relay publication-review v1.3.0 + uses: egohygiene/relay/.github/workflows/publication-review.yml@ + with: + artifact-name: "antidote-publication-site-${{ github.sha }}" + expected-base-url: "https://antidote.egohygiene.io/" + expected-source-revision: "${{ github.sha }}" + fallback-base-url: "https://egohygiene.github.io/antidote/" + required-routes: '["", "paper/", "magazine/", "downloads/"]' + + deploy_site: + name: Deploy reviewed publication bytes + if: >- + ${{ + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref_type == 'branch' && + github.ref_name == github.event.repository.default_branch + }} + needs: build_site + permissions: + actions: read + contents: read + id-token: write + pages: write + # egohygiene/relay publication-pages v1.3.0 + uses: egohygiene/relay/.github/workflows/publication-pages.yml@ + with: + artifact-name: "antidote-publication-site-${{ github.sha }}" + expected-base-url: "https://antidote.egohygiene.io/" + expected-source-revision: "${{ github.sha }}" + fallback-base-url: "https://egohygiene.github.io/antidote/" + required-routes: '["", "paper/", "magazine/", "downloads/"]' + verify-fallback: true +``` + +The public catalog keeps the magazine slot truthful while it is planned or +draft: its landing page exists, but it has no version, artifact, release, DOI, +or checksum claims. Antidote remains independently buildable through Make and +Task if Relay is unavailable. + +Refs egohygiene/relay#38. diff --git a/examples/workflows/publication-pages-reflector.md b/examples/workflows/publication-pages-reflector.md new file mode 100644 index 0000000..7e1d46a --- /dev/null +++ b/examples/workflows/publication-pages-reflector.md @@ -0,0 +1,93 @@ +# Reflector publication Pages caller + +This is the incremental migration pattern for Reflector after Relay v1.3.0 is +released. Reflector's native `task pages:check`, manuscript, magazine, DOI, +release metadata, artifact names, and site staging remain authoritative. Relay +replaces only duplicated lifecycle validation, Pages deployment, and remote +byte proof. + +```yaml +name: Reflector publication hub + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build_site: + name: Build product-owned publication site + permissions: + contents: read + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout Reflector + uses: actions/checkout@ + with: + fetch-depth: 0 + persist-credentials: false + + # Preserve Reflector's existing pinned native publication dependencies. + - name: Build and validate Reflector publication site + run: task pages:check PAGES_OUTPUT_DIR="_site" + + - name: Upload caller-built static site + uses: actions/upload-artifact@ + with: + name: "reflector-publication-site-${{ github.sha }}" + path: "_site" + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + review_site: + name: Review publication bytes + if: "${{ github.event_name == 'pull_request' }}" + needs: build_site + permissions: + actions: read + contents: read + # egohygiene/relay publication-review v1.3.0 + uses: egohygiene/relay/.github/workflows/publication-review.yml@ + with: + artifact-name: "reflector-publication-site-${{ github.sha }}" + expected-base-url: "https://reflector.egohygiene.io/" + expected-source-revision: "${{ github.sha }}" + fallback-base-url: "https://egohygiene.github.io/reflector/" + required-routes: '["", "paper/", "magazine/", "downloads/"]' + + deploy_site: + name: Deploy reviewed publication bytes + if: >- + ${{ + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref_type == 'branch' && + github.ref_name == github.event.repository.default_branch + }} + needs: build_site + permissions: + actions: read + contents: read + id-token: write + pages: write + # egohygiene/relay publication-pages v1.3.0 + uses: egohygiene/relay/.github/workflows/publication-pages.yml@ + with: + artifact-name: "reflector-publication-site-${{ github.sha }}" + expected-base-url: "https://reflector.egohygiene.io/" + expected-source-revision: "${{ github.sha }}" + fallback-base-url: "https://egohygiene.github.io/reflector/" + required-routes: '["", "paper/", "magazine/", "downloads/"]' + verify-fallback: true +``` + +The migration must preserve Reflector's published URLs, DOI and release +history. Its current Pages workflow remains the rollback path until the pinned +Relay call passes a real default-branch deployment and both public endpoints. + +Refs egohygiene/relay#38. diff --git a/release.json b/release.json index 250177c..5883cf3 100644 --- a/release.json +++ b/release.json @@ -1,6 +1,6 @@ { "$schema": "./schemas/release.schema.json", "schema": "egohygiene.relay-release/v1", - "version": "v1.2.0", + "version": "v1.3.0", "update_major_alias": true } diff --git a/scripts/validate_actions.py b/scripts/validate_actions.py index b08ba35..e517728 100644 --- a/scripts/validate_actions.py +++ b/scripts/validate_actions.py @@ -165,6 +165,41 @@ def validate_workflow_metadata(repository_root: Path, errors: list[str]) -> None if "uses: ./actions/repository-intelligence" in reusable: errors.append("reusable workflow must not resolve the action from the caller checkout") + review = (workflow_root / "publication-review.yml").read_text(encoding="utf-8") + if "workflow_call:" not in review: + errors.append("publication-review workflow is not callable") + if "uses: $/actions/validate-publication-site" not in review: + errors.append( + "publication-review must validate through its exact Relay revision" + ) + if "uses: ./actions/validate-publication-site" in review: + errors.append( + "publication-review must not resolve validation from the caller checkout" + ) + if "pages: write" in review or "id-token: write" in review: + errors.append("publication-review must remain statically read-only") + if "actions/checkout@" in review: + errors.append("publication-review workflow must never check out caller source") + + publication = (workflow_root / "publication-pages.yml").read_text(encoding="utf-8") + if "workflow_call:" not in publication: + errors.append("publication-pages workflow is not callable") + if "uses: $/.github/workflows/publication-review.yml" not in publication: + errors.append("publication-pages must consume the read-only review workflow") + for action in ("validate-publication-site", "verify-publication-pages"): + exact = f"uses: $/actions/{action}" + caller = f"uses: ./actions/{action}" + if exact not in publication: + errors.append( + f"publication-pages workflow must invoke {action} from its exact Relay revision" + ) + if caller in publication: + errors.append( + f"publication-pages workflow must not resolve {action} from the caller checkout" + ) + if "actions/checkout@" in publication: + errors.append("publication-pages workflow must never check out caller source") + release = (workflow_root / "release.yml").read_text(encoding="utf-8") for required in ( "workflow_dispatch:", diff --git a/tests/fixtures/publication-sites/custom-domain.json b/tests/fixtures/publication-sites/custom-domain.json new file mode 100644 index 0000000..506b1f0 --- /dev/null +++ b/tests/fixtures/publication-sites/custom-domain.json @@ -0,0 +1,6 @@ +{ + "base_url": "https://publication.example.test/", + "fallback_base_url": "https://example.github.io/publication/", + "required_routes": ["", "paper/", "magazine/", "downloads/"], + "site_directory": "dist/custom-domain" +} diff --git a/tests/fixtures/publication-sites/repository-subpath.json b/tests/fixtures/publication-sites/repository-subpath.json new file mode 100644 index 0000000..dfb6d1a --- /dev/null +++ b/tests/fixtures/publication-sites/repository-subpath.json @@ -0,0 +1,6 @@ +{ + "base_url": "https://example.github.io/publication/", + "fallback_base_url": null, + "required_routes": ["", "paper/", "magazine/", "downloads/"], + "site_directory": "dist/repository-subpath" +} diff --git a/tests/test_action_catalog.py b/tests/test_action_catalog.py index 0d75d1b..64e68b5 100644 --- a/tests/test_action_catalog.py +++ b/tests/test_action_catalog.py @@ -32,6 +32,8 @@ def test_expected_first_release_actions_are_present(self) -> None: "actions/normalize-repository-report", "actions/publish-report-snapshot", "actions/repository-intelligence", + "actions/validate-publication-site", + "actions/verify-publication-pages", }, ) @@ -44,7 +46,11 @@ def test_reusable_workflow_resolves_its_own_relay_revision(self) -> None: self.assertNotIn("uses: ./actions/repository-intelligence", workflow) self.assertEqual( validator.discovered_reusable_workflows(REPOSITORY_ROOT), - {".github/workflows/repository-intelligence.yml"}, + { + ".github/workflows/publication-pages.yml", + ".github/workflows/publication-review.yml", + ".github/workflows/repository-intelligence.yml", + }, ) def test_dashboard_action_guards_artifact_path_scope(self) -> None: diff --git a/tests/test_publication_evidence_schemas.py b/tests/test_publication_evidence_schemas.py new file mode 100644 index 0000000..d5cbb6b --- /dev/null +++ b/tests/test_publication_evidence_schemas.py @@ -0,0 +1,158 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT + +"""Keep publication evidence payloads aligned with their versioned schemas.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import tempfile +import unittest + +from tests.test_publication_pages_verification import WORKFLOW_REF, WORKFLOW_SHA, verifier +from tests.test_publication_site_validation import REVISION, build_site, validator + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +LOCAL_SCHEMA = ( + REPOSITORY_ROOT + / "actions/validate-publication-site/schemas/" + "publication-site-validation-evidence.schema.json" +) +REMOTE_SCHEMA = ( + REPOSITORY_ROOT + / "actions/verify-publication-pages/schemas/" + "publication-pages-evidence.schema.json" +) +PUBLICATION_SITE_SCHEMA = ( + REPOSITORY_ROOT + / "actions/validate-publication-site/contracts/publication-site.schema.json" +) + + +def assert_top_level_contract( + testcase: unittest.TestCase, + value: dict, + definition: dict, +) -> None: + """Check required keys, closed shape, and direct const values.""" + + testcase.assertTrue(set(definition["required"]).issubset(value)) + testcase.assertEqual(set(value), set(definition["properties"])) + for key, property_schema in definition["properties"].items(): + if "const" in property_schema: + testcase.assertEqual(value[key], property_schema["const"]) + + +class PublicationEvidenceSchemaTests(unittest.TestCase): + """Exercise success, predeploy, and sanitized failure evidence shapes.""" + + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.workspace = Path(temporary.name) + + def test_vendored_beacon_public_contract_is_exact_and_closed(self) -> None: + raw = PUBLICATION_SITE_SCHEMA.read_bytes() + schema = json.loads(raw) + self.assertEqual(hashlib.sha256(raw).hexdigest(), validator.PUBLIC_SCHEMA_SHA256) + self.assertEqual(schema["$defs"]["site"]["additionalProperties"], False) + self.assertEqual(schema["$defs"]["slot"]["additionalProperties"], False) + self.assertEqual(schema["$defs"]["resource"]["additionalProperties"], False) + self.assertEqual(schema["properties"]["schema"]["const"], validator.CATALOG_SCHEMA) + + def test_local_success_and_failure_match_the_closed_schema_branches(self) -> None: + fixture, site = build_site(self.workspace, "repository-subpath") + result = validator.validate_publication_site( + workspace=self.workspace, + site_directory=fixture["site_directory"], + expected_base_url=fixture["base_url"], + expected_source_revision=REVISION, + required_routes_json=json.dumps(fixture["required_routes"]), + ) + schema = json.loads(LOCAL_SCHEMA.read_text(encoding="utf-8")) + assert_top_level_contract(self, result.evidence(), schema["$defs"]["success"]) + + (site / "site.json").write_text("{invalid\n", encoding="utf-8") + evidence_path = self.workspace / ".relay/local-failure.json" + exit_code = validator.main( + [ + "--workspace", + str(self.workspace), + "--site-directory", + fixture["site_directory"], + "--expected-base-url", + fixture["base_url"], + "--expected-source-revision", + REVISION, + "--evidence-output", + ".relay/local-failure.json", + ] + ) + self.assertEqual(exit_code, 2) + failure = json.loads(evidence_path.read_text(encoding="utf-8")) + assert_top_level_contract(self, failure, schema["$defs"]["failure"]) + + def test_deployment_and_predeploy_evidence_match_schema_literals(self) -> None: + fixture, _ = build_site(self.workspace, "repository-subpath") + validation = validator.validate_publication_site( + workspace=self.workspace, + site_directory=fixture["site_directory"], + expected_base_url=fixture["base_url"], + expected_source_revision=REVISION, + required_routes_json=json.dumps(fixture["required_routes"]), + ) + target = { + "base_url": fixture["base_url"], + "catalog_sha256": validation.catalog_sha256, + "checksum_sha256": validation.checksum_sha256, + "file_count": len(validation.files), + "route_count": len(validation.routes), + "routes": [ + {"path": path, "sha256": "d" * 64} + for path in validation.routes + ], + "status": "passed", + "target": "canonical", + } + deployment = verifier.verification_evidence( + validation=validation, + workflow_ref=WORKFLOW_REF, + workflow_sha=WORKFLOW_SHA, + targets=[target], + ) + schema = json.loads(REMOTE_SCHEMA.read_text(encoding="utf-8")) + assert_top_level_contract( + self, deployment, schema["$defs"]["deployment"] + ) + + evidence_path = self.workspace / ".relay/predeploy.json" + exit_code = verifier.main( + [ + "--workspace", + str(self.workspace), + "--site-directory", + fixture["site_directory"], + "--expected-base-url", + fixture["base_url"], + "--expected-source-revision", + REVISION, + "--configuration-only", + "true", + "--workflow-ref", + WORKFLOW_REF, + "--workflow-sha", + WORKFLOW_SHA, + "--evidence-output", + ".relay/predeploy.json", + ] + ) + self.assertEqual(exit_code, 0) + predeploy = json.loads(evidence_path.read_text(encoding="utf-8")) + assert_top_level_contract(self, predeploy, schema["$defs"]["predeploy"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_publication_pages_verification.py b/tests/test_publication_pages_verification.py new file mode 100644 index 0000000..7a0aef8 --- /dev/null +++ b/tests/test_publication_pages_verification.py @@ -0,0 +1,471 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT + +"""Tests for bounded, byte-exact publication Pages verification.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import sys +import tempfile +import time +import unittest +from unittest import mock +from urllib.error import URLError +from urllib.parse import urljoin, urlsplit, urlunsplit + +from tests.test_publication_site_validation import REVISION, build_site + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ( + REPOSITORY_ROOT + / "actions/verify-publication-pages/scripts/verify_publication_pages.py" +) +SPEC = importlib.util.spec_from_file_location("publication_pages_verification", SCRIPT) +assert SPEC is not None +assert SPEC.loader is not None +verifier = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = verifier +SPEC.loader.exec_module(verifier) +WORKFLOW_SHA = "c" * 40 +WORKFLOW_REF = ( + "egohygiene/relay/.github/workflows/publication-pages.yml@" + WORKFLOW_SHA +) + + +def remote_map(site: Path, fixture: dict) -> dict[str, bytes]: + """Return exact canonical/fallback URL bytes for a local fixture.""" + + catalog = json.loads((site / "site.json").read_text(encoding="utf-8")) + bases = [fixture["base_url"]] + if fixture["fallback_base_url"] is not None: + bases.append(fixture["fallback_base_url"]) + values: dict[str, bytes] = {} + files = [path for path in site.rglob("*") if path.is_file()] + for base in bases: + for path in files: + relative = path.relative_to(site).as_posix() + values[urljoin(base, relative)] = path.read_bytes() + for route in catalog["routes"]: + entrypoint = route.get("file") or verifier.site_contract.route_entrypoint( + route["path"] + ) + values[urljoin(base, route["path"])] = (site / entrypoint).read_bytes() + return values + + +def without_query(url: str) -> str: + """Map Relay's safe cache-busted request to fixture bytes.""" + + parsed = urlsplit(url) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + + +class PublicationPagesVerificationTests(unittest.TestCase): + """Keep remote verification exact, deterministic, and bounded.""" + + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.workspace = Path(temporary.name) + + def verify(self, fixture_name: str, *, fallback: bool) -> dict: + """Verify one local fixture through an in-memory HTTPS transport.""" + + fixture, site = build_site(self.workspace, fixture_name) + responses = remote_map(site, fixture) + + def fetch(url: str, timeout: float, max_bytes: int, deadline: float) -> object: + self.assertGreater(timeout, 0) + self.assertGreater(deadline, time.monotonic()) + self.assertEqual(urlsplit(url).query, f"relay_revision={REVISION}") + plain = without_query(url) + if plain not in responses: + raise URLError("not ready") + self.assertLessEqual(len(responses[plain]), max_bytes) + return verifier.FetchResult(body=responses[plain], final_url=url, status=200) + + return verifier.verify_publication_pages( + workspace=self.workspace, + site_directory=fixture["site_directory"], + expected_base_url=fixture["base_url"], + expected_source_revision=REVISION, + required_routes_json=json.dumps(fixture["required_routes"]), + catalog_path="site.json", + checksum_path="SHA256SUMS", + fallback_base_url=fixture["fallback_base_url"] or "", + verify_fallback=fallback, + deployment_url=fixture["base_url"], + workflow_ref=WORKFLOW_REF, + workflow_sha=WORKFLOW_SHA, + policy=verifier.RetryPolicy(2, 0, 5, 30), + fetch=fetch, + sleep=lambda _: None, + ) + + def test_custom_domain_and_fallback_bytes_produce_stable_evidence(self) -> None: + first = self.verify("custom-domain", fallback=True) + second = self.verify("custom-domain", fallback=True) + + self.assertEqual(first, second) + self.assertEqual(first["result"], "passed") + self.assertEqual(first["source_revision"], REVISION) + self.assertEqual([item["target"] for item in first["targets"]], [ + "canonical", + "fallback", + ]) + self.assertEqual(first["workflow"]["sha"], WORKFLOW_SHA) + + def test_repository_subpath_verifies_without_a_fallback(self) -> None: + evidence = self.verify("repository-subpath", fallback=False) + + self.assertEqual(len(evidence["targets"]), 1) + self.assertEqual( + evidence["targets"][0]["base_url"], + "https://example.github.io/publication/", + ) + + def test_remote_byte_drift_fails_without_leaking_the_body(self) -> None: + fixture, site = build_site(self.workspace, "custom-domain") + responses = remote_map(site, fixture) + target = urljoin(fixture["base_url"], "files/paper.pdf") + responses[target] = b"x" * len(responses[target]) + + def fetch(url: str, _: float, __: int, ___: float) -> object: + return verifier.FetchResult( + body=responses[without_query(url)], final_url=url, status=200 + ) + + with self.assertRaisesRegex(verifier.VerificationError, "bounded retries") as caught: + verifier.verify_publication_pages( + workspace=self.workspace, + site_directory=fixture["site_directory"], + expected_base_url=fixture["base_url"], + expected_source_revision=REVISION, + required_routes_json=json.dumps(fixture["required_routes"]), + catalog_path="site.json", + checksum_path="SHA256SUMS", + fallback_base_url=fixture["fallback_base_url"], + verify_fallback=False, + deployment_url=fixture["base_url"], + workflow_ref=WORKFLOW_REF, + workflow_sha=WORKFLOW_SHA, + policy=verifier.RetryPolicy(1, 0, 5, 30), + fetch=fetch, + sleep=lambda _: None, + ) + self.assertNotIn("secret remote", str(caught.exception)) + + def test_retry_and_redirect_rules_are_bounded(self) -> None: + calls = 0 + + def eventually(url: str, _: float, __: int, ___: float) -> object: + nonlocal calls + calls += 1 + if calls < 3: + raise URLError("certificate pending") + return verifier.FetchResult( + body=b"ready", + final_url=url, + status=200, + ) + + result = verifier.fetch_with_retry( + "https://publication.example.test/", + "site.json", + revision=REVISION, + expected_bytes=5, + policy=verifier.RetryPolicy(3, 0, 5, 30), + deadline=time.monotonic() + 30, + declared_bases=("https://publication.example.test/",), + fetch=eventually, + sleep=lambda _: None, + ) + self.assertEqual(result.body, b"ready") + self.assertEqual(calls, 3) + + stale_calls = 0 + + def stale_then_current(url: str, _: float, __: int, ___: float) -> object: + nonlocal stale_calls + stale_calls += 1 + body = b"stale" if stale_calls < 3 else b"fresh" + return verifier.FetchResult(body=body, final_url=url, status=200) + + current = verifier.fetch_with_retry( + "https://publication.example.test/", + "site.json", + revision=REVISION, + expected_bytes=5, + expected_body=b"fresh", + policy=verifier.RetryPolicy(3, 0, 5, 30), + deadline=time.monotonic() + 30, + declared_bases=("https://publication.example.test/",), + fetch=stale_then_current, + sleep=lambda _: None, + ) + self.assertEqual(current.body, b"fresh") + self.assertEqual(stale_calls, 3) + + redirect_calls = 0 + + def redirected(url: str, _: float, __: int, ___: float) -> object: + nonlocal redirect_calls + redirect_calls += 1 + return verifier.FetchResult( + body=b"", + final_url=url, + status=302, + redirect_url=( + "https://attacker.example/site.json?relay_revision=" + REVISION + ), + ) + + with self.assertRaisesRegex(verifier.VerificationError, "bounded retries"): + verifier.fetch_with_retry( + "https://publication.example.test/", + "site.json", + revision=REVISION, + expected_bytes=5, + policy=verifier.RetryPolicy(1, 0, 5, 30), + deadline=time.monotonic() + 30, + declared_bases=("https://publication.example.test/",), + fetch=redirected, + sleep=lambda _: None, + ) + self.assertEqual(redirect_calls, 1) + + def test_policy_workflow_identity_and_deployment_url_fail_closed(self) -> None: + for policy in ( + verifier.RetryPolicy(13, 0, 5, 30), + verifier.RetryPolicy(1, 31, 5, 30), + verifier.RetryPolicy(1, 0, 61, 30), + verifier.RetryPolicy(1, 0, 5, 601), + ): + with self.subTest(policy=policy): + with self.assertRaises(verifier.VerificationError): + policy.validate() + + with self.assertRaisesRegex(verifier.VerificationError, "Relay publication workflow"): + verifier.validate_workflow_identity(WORKFLOW_REF, "d" * 40) + with self.assertRaisesRegex(verifier.VerificationError, "Relay publication workflow"): + verifier.validate_workflow_identity( + "egohygiene/relay/.github/workflows/publication-pages.yml@evil@" + + WORKFLOW_SHA, + WORKFLOW_SHA, + ) + + fixture, site = build_site(self.workspace, "custom-domain") + responses = remote_map(site, fixture) + + def fetch(url: str, _: float, __: int, ___: float) -> object: + return verifier.FetchResult( + body=responses[without_query(url)], final_url=url, status=200 + ) + + with self.assertRaisesRegex(verifier.VerificationError, "deployment URL"): + verifier.verify_publication_pages( + workspace=self.workspace, + site_directory=fixture["site_directory"], + expected_base_url=fixture["base_url"], + expected_source_revision=REVISION, + required_routes_json="[]", + catalog_path="site.json", + checksum_path="SHA256SUMS", + fallback_base_url=fixture["fallback_base_url"], + verify_fallback=False, + deployment_url="https://wrong.example.test/", + workflow_ref=WORKFLOW_REF, + workflow_sha=WORKFLOW_SHA, + policy=verifier.RetryPolicy(1, 0, 5, 30), + fetch=fetch, + sleep=lambda _: None, + ) + + def test_catalog_fallback_requires_explicit_remote_authorization(self) -> None: + fixture, _ = build_site(self.workspace, "custom-domain") + common = { + "workspace": self.workspace, + "site_directory": fixture["site_directory"], + "expected_base_url": fixture["base_url"], + "expected_source_revision": REVISION, + "required_routes_json": json.dumps(fixture["required_routes"]), + "catalog_path": "site.json", + "checksum_path": "SHA256SUMS", + "fallback_base_url": "", + "deployment_url": fixture["base_url"], + "workflow_ref": WORKFLOW_REF, + "workflow_sha": WORKFLOW_SHA, + "policy": verifier.RetryPolicy(1, 0, 5, 30), + } + _, _, authorized_fallback = verifier.validate_verification_configuration( + **common, + verify_fallback=False, + ) + self.assertIsNone(authorized_fallback) + + with self.assertRaisesRegex( + verifier.VerificationError, "explicit matching fallback-base-url" + ): + verifier.validate_verification_configuration( + **common, + verify_fallback=True, + ) + + calls: list[str] = [] + + def redirect_to_unpinned_fallback( + url: str, _: float, __: int, ___: float + ) -> object: + calls.append(url) + return verifier.FetchResult( + body=b"", + final_url=url, + status=302, + redirect_url=verifier.cache_busted_url( + fixture["fallback_base_url"], "site.json", REVISION + ), + ) + + with self.assertRaisesRegex(verifier.VerificationError, "bounded retries"): + verifier.fetch_with_retry( + fixture["base_url"], + "site.json", + revision=REVISION, + expected_bytes=10, + policy=verifier.RetryPolicy(1, 0, 5, 30), + deadline=time.monotonic() + 30, + declared_bases=(fixture["base_url"],), + fetch=redirect_to_unpinned_fallback, + sleep=lambda _: None, + ) + self.assertEqual(len(calls), 1) + + def test_public_endpoint_and_redirect_policy_blocks_ssrf_before_fetch(self) -> None: + for url in ( + "http://publication.example.test/", + "https://localhost/", + "https://127.0.0.1/", + "https://169.254.169.254/", + "https://publication.example.test:8443/", + "https://internal/", + ): + with self.subTest(url=url): + with self.assertRaises( + (verifier.VerificationError, verifier.site_contract.ValidationError) + ): + verifier.validate_public_base_url(url, "fixture") + + for redirect in ( + "http://publication.example.test/site.json", + "https://127.0.0.1/site.json", + "https://169.254.169.254/latest/meta-data/", + "https://publication.example.test:8443/site.json", + "https://undeclared.example/site.json", + ): + calls: list[str] = [] + + def fetch(url: str, _: float, __: int, ___: float) -> object: + calls.append(url) + return verifier.FetchResult( + body=b"", + final_url=url, + status=302, + redirect_url=redirect, + ) + + with self.subTest(redirect=redirect): + with self.assertRaisesRegex(verifier.VerificationError, "bounded retries"): + verifier.fetch_with_retry( + "https://publication.example.test/", + "site.json", + revision=REVISION, + expected_bytes=10, + policy=verifier.RetryPolicy(1, 0, 5, 30), + deadline=time.monotonic() + 30, + declared_bases=("https://publication.example.test/",), + fetch=fetch, + sleep=lambda _: None, + ) + self.assertEqual(len(calls), 1) + + def test_cache_busting_preserves_exact_repository_subpath(self) -> None: + self.assertEqual( + verifier.cache_busted_url( + "https://example.github.io/publication/", "paper/", REVISION + ), + "https://example.github.io/publication/paper/" + f"?relay_revision={REVISION}", + ) + + def test_exact_byte_fetch_refuses_content_encoding(self) -> None: + class EncodedResponse: + status = 200 + headers = {"Content-Encoding": "gzip"} + + def __enter__(self) -> "EncodedResponse": + return self + + def __exit__(self, *_: object) -> None: + return None + + class Opener: + request = None + + def open(self, request: object, *, timeout: float) -> EncodedResponse: + self.request = request + self.timeout = timeout + return EncodedResponse() + + opener = Opener() + with ( + mock.patch.object(verifier, "assert_public_resolution"), + mock.patch.object(verifier, "build_opener", return_value=opener), + self.assertRaisesRegex( + verifier.VerificationError, + "unsupported content encoding", + ), + ): + verifier.default_fetch( + "https://publication.example.test/site.json", + timeout=5, + max_bytes=1024, + deadline=time.monotonic() + 5, + ) + self.assertEqual(opener.request.get_header("Accept-encoding"), "identity") + + def test_failure_evidence_is_sanitized_and_configuration_only_is_offline(self) -> None: + fixture, _ = build_site(self.workspace, "repository-subpath") + evidence = self.workspace / ".relay/verification-failure.json" + result = verifier.main( + [ + "--workspace", + str(self.workspace), + "--site-directory", + fixture["site_directory"], + "--expected-base-url", + fixture["base_url"], + "--expected-source-revision", + REVISION, + "--configuration-only", + "true", + "--workflow-ref", + "secret-token-not-a-workflow-ref", + "--workflow-sha", + WORKFLOW_SHA, + "--evidence-output", + ".relay/verification-failure.json", + ] + ) + self.assertEqual(result, 2) + failure = evidence.read_text(encoding="utf-8") + self.assertIn("predeploy-configuration", failure) + self.assertNotIn("secret-token", failure) + self.assertNotIn(str(self.workspace), failure) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_publication_pages_workflow.py b/tests/test_publication_pages_workflow.py new file mode 100644 index 0000000..398296e --- /dev/null +++ b/tests/test_publication_pages_workflow.py @@ -0,0 +1,146 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT + +"""Structural tests for the reusable publication Pages authority boundary.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = REPOSITORY_ROOT / ".github/workflows/publication-pages.yml" +REVIEW_WORKFLOW = REPOSITORY_ROOT / ".github/workflows/publication-review.yml" +VALIDATE_WORKFLOW = REPOSITORY_ROOT / ".github/workflows/validate.yml" + + +class PublicationPagesWorkflowTests(unittest.TestCase): + """Keep validation, review, deployment, and verification separated.""" + + @classmethod + def setUpClass(cls) -> None: + cls.text = WORKFLOW.read_text(encoding="utf-8") + cls.review_text = REVIEW_WORKFLOW.read_text(encoding="utf-8") + + def test_workflow_never_checks_out_or_builds_caller_content(self) -> None: + for workflow in (self.review_text, self.text): + self.assertNotIn("actions/checkout@", workflow) + for command in ("make ", "task ", "pandoc", "latexmk", "npm run"): + with self.subTest(command=command): + self.assertNotIn(command, workflow) + self.assertIn("Download caller-built static site", self.review_text) + + def test_review_job_is_read_only_and_owns_the_reviewed_bytes(self) -> None: + review = self.review_text[ + self.review_text.index(" review:"): + ] + self.assertIn("permissions:\n actions: read\n contents: read", review) + self.assertNotIn("pages: write", review) + self.assertNotIn("id-token: write", review) + self.assertIn("uses: $/actions/validate-publication-site", review) + self.assertIn( + 'expected-fallback-base-url: "${{ inputs.fallback-base-url }}"', review + ) + self.assertIn("Upload exact reviewed static site", review) + self.assertIn("include-hidden-files: true", review) + self.assertIn('if: "${{ always() }}"', review) + + def test_deploy_consumes_only_the_exact_reviewed_artifact(self) -> None: + review_call = self.text[ + self.text.index(" review:"):self.text.index(" deploy:") + ] + self.assertIn( + "uses: $/.github/workflows/publication-review.yml", + review_call, + ) + self.assertIn("actions: read", review_call) + self.assertIn("contents: read", review_call) + self.assertNotIn("pages: write", review_call) + self.assertNotIn("id-token: write", review_call) + deploy = self.text[self.text.index(" deploy:"):] + self.assertIn(" - review", deploy) + self.assertIn("pages: write", deploy) + self.assertIn("id-token: write", deploy) + self.assertIn("name: github-pages", deploy) + self.assertIn("needs.review.outputs.review-artifact-name", deploy) + self.assertIn( + 'expected-fallback-base-url: "${{ inputs.fallback-base-url }}"', deploy + ) + self.assertNotIn('name: "${{ inputs.artifact-name }}"', deploy) + revalidate = deploy.index("Revalidate downloaded publication site") + compare = deploy.index("Confirm reviewed site-tree identity") + preflight = deploy.index("Validate deployment and verification configuration") + configure = deploy.index("Configure GitHub Pages") + upload = deploy.index("Upload deployable Pages artifact") + publish = deploy.index("Deploy GitHub Pages") + verify = deploy.index("Verify deployed publication Pages") + self.assertLess(revalidate, compare) + self.assertLess(compare, preflight) + self.assertLess(preflight, configure) + self.assertLess(configure, upload) + self.assertLess(upload, publish) + self.assertLess(publish, verify) + + def test_authority_is_fail_closed_and_runs_do_not_cancel_deployments(self) -> None: + self.assertIn("cancel-in-progress: false", self.text) + authorize = self.text[ + self.text.index(" authorize:"):self.text.index(" review:") + ] + self.assertIn("permissions: {}", authorize) + self.assertIn('GITHUB_EVENT_NAME}" != "push"', self.text) + self.assertIn('GITHUB_EVENT_NAME}" != "workflow_dispatch"', self.text) + self.assertIn('GITHUB_REF_NAME}" != "${DEFAULT_BRANCH}', self.text) + self.assertIn( + "expected-source-revision must equal the caller workflow SHA", + self.review_text, + ) + self.assertNotIn("deploy-enabled", self.text) + review_authorize = self.review_text[ + self.review_text.index(" authorize:"): + self.review_text.index(" review:") + ] + self.assertIn("permissions: {}", review_authorize) + self.assertIn("Relay-reserved artifact prefix", review_authorize) + + def test_remote_dependencies_are_fully_pinned_and_evidence_outputs_are_honest(self) -> None: + remote_uses = [ + line.strip().split("uses: ", 1)[1] + for line in (self.text + "\n" + self.review_text).splitlines() + if line.strip().startswith("uses: ") + and not line.strip().startswith("uses: $/") + ] + self.assertTrue(remote_uses) + for reference in remote_uses: + with self.subTest(reference=reference): + self.assertRegex(reference, r"^[^@]+@[0-9a-f]{40}$") + self.assertIn("jobs.deploy.outputs.evidence-artifact-name", self.text) + self.assertIn("jobs.review.outputs.file-count", self.text) + self.assertIn("jobs.review.outputs.total-bytes", self.text) + self.assertIn("Digest of the exact complete SHA256SUMS bytes", self.text) + + def test_validate_workflow_exercises_the_review_only_artifact_boundary(self) -> None: + validate = VALIDATE_WORKFLOW.read_text(encoding="utf-8") + producer = validate[ + validate.index(" publication-site-fixture:"): + validate.index(" publication-review-smoke:") + ] + smoke = validate[ + validate.index(" publication-review-smoke:"): + validate.index(" publication-review-smoke-outputs:") + ] + outputs = validate[ + validate.index(" publication-review-smoke-outputs:"): + validate.index(" intelligence-smoke:") + ] + self.assertIn("actions/upload-artifact@", producer) + self.assertIn("include-hidden-files: true", producer) + self.assertIn("uses: $/.github/workflows/publication-review.yml", smoke) + self.assertNotIn("pages: write", smoke) + self.assertNotIn("id-token: write", smoke) + self.assertIn("permissions: {}", outputs) + self.assertIn("needs.publication-review-smoke.outputs.site-tree-sha256", outputs) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_publication_site_validation.py b/tests/test_publication_site_validation.py new file mode 100644 index 0000000..83fce81 --- /dev/null +++ b/tests/test_publication_site_validation.py @@ -0,0 +1,817 @@ +# Copyright 2026 Ego Hygiene +# SPDX-License-Identifier: MIT + +"""Tests for the host-neutral publication-site validation boundary.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import shutil +import tempfile +import unittest +from urllib.parse import urljoin + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ( + REPOSITORY_ROOT + / "actions/validate-publication-site/scripts/validate_publication_site.py" +) +SPEC = importlib.util.spec_from_file_location("publication_site_validation", SCRIPT) +assert SPEC is not None +assert SPEC.loader is not None +validator = importlib.util.module_from_spec(SPEC) +import sys + +sys.modules[SPEC.name] = validator +SPEC.loader.exec_module(validator) +FIXTURE_ROOT = REPOSITORY_ROOT / "tests/fixtures/publication-sites" +REVISION = "a" * 40 + + +def write(path: Path, value: str | bytes) -> None: + """Create a fixture file.""" + + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(value, bytes): + path.write_bytes(value) + else: + path.write_text(value, encoding="utf-8") + + +def resource( + site: Path, + *, + identifier: str, + label: str, + media_type: str, + path: str, + route: str | None = None, + base_url: str, + fallback_base_url: str | None, + aliases: list[str] | None = None, +) -> dict: + """Describe a real fixture resource.""" + + target = site / path + public_route = route or path + return { + "aliases": [ + { + "fallback_url": ( + urljoin(fallback_base_url, alias) + if fallback_base_url is not None + else None + ), + "path": alias, + "url": urljoin(base_url, alias), + } + for alias in aliases or [] + ], + "bytes": target.stat().st_size, + "fallback_url": ( + urljoin(fallback_base_url, public_route) + if fallback_base_url is not None + else None + ), + "id": identifier, + "label": label, + "media_type": media_type, + "path": path, + "route": public_route, + "sha256": validator.sha256_file(target), + "url": urljoin(base_url, public_route), + } + + +def build_site( + workspace: Path, + fixture_name: str, + *, + revision: str = REVISION, +) -> tuple[dict, Path]: + """Materialize one checked host-topology fixture with deterministic bytes.""" + + fixture = json.loads( + (FIXTURE_ROOT / f"{fixture_name}.json").read_text(encoding="utf-8") + ) + site = workspace / fixture["site_directory"] + base_url = fixture["base_url"] + fallback = fixture["fallback_base_url"] + pages = { + "index.html": "Hub

Hub

\n", + "paper/index.html": "Paper

Paper

\n", + "magazine/index.html": "Magazine

Planned

\n", + "downloads/index.html": "Downloads

Downloads

\n", + "manifest.webmanifest": ( + '{"description":"Fixture","id":"./","name":"Fixture",' + '"scope":"./","short_name":"Fixture","start_url":"./"}\n' + ), + "publication.json": '{"schema":"fixture.publication/v1"}\n', + "files/paper.pdf": b"%PDF-1.4 fixture\n%%EOF\n", + "antidote.pdf": b"%PDF-1.4 fixture\n%%EOF\n", + ".nojekyll": b"", + } + for relative, value in pages.items(): + write(site / relative, value) + + paper = resource( + site, + identifier="paper-pdf", + label="Paper PDF", + media_type="application/pdf", + path="files/paper.pdf", + base_url=base_url, + fallback_base_url=fallback, + aliases=["antidote.pdf"], + ) + accessible = resource( + site, + identifier="accessible-web", + label="Accessible web paper", + media_type="text/html", + path="paper/index.html", + route="paper/", + base_url=base_url, + fallback_base_url=fallback, + ) + manifest = resource( + site, + identifier="paper-manifest", + label="Paper manifest", + media_type="application/json", + path="publication.json", + base_url=base_url, + fallback_base_url=fallback, + ) + catalog = { + "routes": sorted( + [ + { + "fallback_url": urljoin(fallback, route) if fallback else None, + "id": identifier, + "kind": kind, + "path": route, + "url": urljoin(base_url, route), + } + for identifier, kind, route in ( + ("home", "hub", ""), + ("downloads", "downloads", "downloads/"), + ("web-manifest", "web-manifest", "manifest.webmanifest"), + ("sha256sums", "checksum", "SHA256SUMS"), + ("site-json", "catalog", "site.json"), + ("paper", "slot", "paper/"), + ("magazine", "slot", "magazine/"), + ("paper:paper-pdf", "artifact", "files/paper.pdf"), + ("paper:paper-pdf", "artifact-alias", "antidote.pdf"), + ("paper:paper-manifest", "manifest", "publication.json"), + ) + ], + key=lambda item: (item["path"], item["kind"], item["id"]), + ), + "schema": "beacon.publication-hub/v1", + "schema_version": "1.0.0", + "site": { + "canonical_base_url": base_url, + "description": "Fixture publication hub", + "fallback_base_url": fallback, + "id": "fixture-publication", + "language": "en", + "publisher": "Fixture", + "revision": revision, + "stage": "published", + "theme": "neutral", + "title": "Fixture publication", + }, + "slots": [ + { + "artifacts": [accessible, paper], + "fallback_url": urljoin(fallback, "paper/") if fallback else None, + "id": "paper", + "kind": "research-paper", + "landing": {"mode": "resource", "resource_id": "accessible-web"}, + "manifests": [manifest], + "order": 1, + "route": "paper/", + "status": "available", + "summary": "Available paper", + "source": { + "repository": "https://github.com/example/publication", + "revision": revision, + }, + "title": "Paper", + "url": urljoin(base_url, "paper/"), + "version": "0.1.0", + }, + { + "fallback_url": ( + urljoin(fallback, "magazine/") if fallback else None + ), + "id": "magazine", + "kind": "magazine", + "landing": {"mode": "generated"}, + "order": 2, + "route": "magazine/", + "status": "planned", + "summary": "Magazine is planned", + "title": "Magazine", + "url": urljoin(base_url, "magazine/"), + }, + ], + "source": { + "catalog_sha256": "b" * 64, + "repository": "https://github.com/example/publication", + "revision": revision, + }, + } + write(site / "site.json", json.dumps(catalog, indent=2, sort_keys=True) + "\n") + checksum_files = sorted( + ( + path + for path in site.rglob("*") + if path.is_file() and path.name != "SHA256SUMS" + ), + key=lambda item: item.relative_to(site).as_posix(), + ) + lines = [ + f"{validator.sha256_file(path)} {path.relative_to(site).as_posix()}" + for path in checksum_files + ] + write(site / "SHA256SUMS", "\n".join(lines) + "\n") + return fixture, site + + +class PublicationSiteValidationTests(unittest.TestCase): + """Keep Relay's local trust boundary strict and host-neutral.""" + + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.workspace = Path(temporary.name) + + def validate(self, fixture: dict) -> object: + """Validate a materialized fixture.""" + + return validator.validate_publication_site( + workspace=self.workspace, + site_directory=fixture["site_directory"], + expected_base_url=fixture["base_url"], + expected_source_revision=REVISION, + required_routes_json=json.dumps(fixture["required_routes"]), + ) + + def test_custom_domain_fixture_passes_with_empty_nojekyll(self) -> None: + fixture, _ = build_site(self.workspace, "custom-domain") + + result = self.validate(fixture) + + self.assertEqual(result.canonical_base_url, fixture["base_url"]) + self.assertEqual(result.fallback_base_url, fixture["fallback_base_url"]) + self.assertEqual(result.source_revision, REVISION) + self.assertEqual(len(result.routes), 10) + self.assertEqual(result.file_count, 11) + self.assertGreater(result.total_bytes, 0) + self.assertRegex(result.site_tree_sha256, r"^[0-9a-f]{64}$") + + def test_repository_subpath_fixture_passes_without_fallback(self) -> None: + fixture, _ = build_site(self.workspace, "repository-subpath") + + result = self.validate(fixture) + + self.assertEqual(result.canonical_base_url, fixture["base_url"]) + self.assertIsNone(result.fallback_base_url) + self.assertIn(("paper/", "paper/index.html"), result.route_files) + + def test_planned_slot_cannot_claim_issue_or_artifact(self) -> None: + fixture, site = build_site(self.workspace, "custom-domain") + catalog_path = site / "site.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + magazine = next(slot for slot in catalog["slots"] if slot["id"] == "magazine") + magazine["issue_number"] = "1" + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + self.refresh_checksums(site) + + with self.assertRaisesRegex( + validator.ValidationError, + "planned slot|unsupported (?:schema )?field", + ): + self.validate(fixture) + + fixture, site = build_site(self.workspace, "repository-subpath") + catalog_path = site / "site.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + magazine = next(slot for slot in catalog["slots"] if slot["id"] == "magazine") + magazine["extensions"] = {"nested": {"release-url": "not-published"}} + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + self.refresh_checksums(site) + with self.assertRaisesRegex(validator.ValidationError, "reserved publication key"): + self.validate(fixture) + + def test_available_slot_requires_evidence_and_alias_bytes_must_match(self) -> None: + fixture, site = build_site(self.workspace, "custom-domain") + catalog_path = site / "site.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + paper = next(slot for slot in catalog["slots"] if slot["id"] == "paper") + paper["artifacts"] = [] + paper["manifests"] = [] + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + self.refresh_checksums(site) + with self.assertRaisesRegex( + validator.ValidationError, + "version, and artifact|too few items", + ): + self.validate(fixture) + + fixture, site = build_site(self.workspace, "repository-subpath") + (site / "antidote.pdf").write_bytes(b"different alias bytes\n") + self.refresh_checksums(site) + with self.assertRaisesRegex(validator.ValidationError, "alias.*bytes"): + self.validate(fixture) + + def test_resource_route_claims_duplicate_json_and_evidence_paths_fail(self) -> None: + fixture, site = build_site(self.workspace, "custom-domain") + catalog_path = site / "site.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + artifact_route = next( + route for route in catalog["routes"] if route["path"] == "files/paper.pdf" + ) + artifact_route["kind"] = "slot" + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + self.refresh_checksums(site) + with self.assertRaisesRegex( + validator.ValidationError, "compatible declared route|incompatible route id" + ): + self.validate(fixture) + + fixture, site = build_site(self.workspace, "repository-subpath") + raw = (site / "site.json").read_text(encoding="utf-8") + (site / "site.json").write_text( + raw.replace('"schema":', '"schema": "duplicate",\n "schema":', 1), + encoding="utf-8", + ) + self.refresh_checksums(site) + with self.assertRaisesRegex(validator.ValidationError, "duplicate key"): + self.validate(fixture) + + fixture, site = build_site(self.workspace, "custom-domain") + for value, message in ( + ("../outside.json", "safe portable path"), + (f"{fixture['site_directory']}/evidence.json", "outside the public site"), + (".github/evidence.json", "protected root"), + ): + with self.subTest(value=value): + with self.assertRaisesRegex(validator.ValidationError, message): + validator.resolve_workspace_output( + self.workspace, value, site=site + ) + + linked = self.workspace / "linked-evidence" + linked.symlink_to(self.workspace / "outside-evidence") + with self.assertRaisesRegex(validator.ValidationError, "symbolic link"): + validator.resolve_workspace_output( + self.workspace, "linked-evidence/result.json", site=site + ) + + def test_landing_resource_ids_routes_and_aliases_are_closed_world(self) -> None: + mutations = { + "dangling-landing": lambda catalog: catalog["slots"][1].update( + {"landing": {"mode": "resource", "resource_id": "missing"}} + ), + "generated-resource-id": lambda catalog: catalog["slots"][1][ + "landing" + ].update({"resource_id": "missing"}), + "duplicate-resource-id": lambda catalog: catalog["slots"][0][ + "manifests" + ][0].update({"id": "paper-pdf"}), + "orphan-resource-route": lambda catalog: catalog["routes"].append( + { + "fallback_url": None, + "id": "paper:orphan", + "kind": "artifact", + "path": "orphan.pdf", + "url": "https://example.github.io/publication/orphan.pdf", + } + ), + "ghost-slot-route": lambda catalog: catalog["routes"].append( + { + "fallback_url": None, + "id": "ghost", + "kind": "slot", + "path": "ghost/", + "url": "https://example.github.io/publication/ghost/", + } + ), + "slot-file-drift": lambda catalog: next( + route + for route in catalog["routes"] + if route["path"] == "magazine/" + ).update({"file": "other.html"}), + } + messages = { + "dangling-landing": "unknown resource", + "generated-resource-id": "only valid for resource mode|schema shape", + "duplicate-resource-id": "repeats resource id", + "orphan-resource-route": "missing or empty|orphan publication route", + "ghost-slot-route": "undeclared extra routes", + "slot-file-drift": "compatible slot route", + } + for name, mutate in mutations.items(): + with self.subTest(name=name): + child = self.workspace / name + child.mkdir() + fixture, site = build_site(child, "repository-subpath") + fixture["site_directory"] = f"{name}/{fixture['site_directory']}" + catalog_path = site / "site.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + if name == "orphan-resource-route": + write(site / "orphan.pdf", b"orphan\n") + elif name == "ghost-slot-route": + write(site / "ghost/index.html", "

Ghost

\n") + elif name == "slot-file-drift": + write(site / "other.html", "

Wrong landing

\n") + mutate(catalog) + catalog["routes"].sort( + key=lambda item: (item["path"], item["kind"], item["id"]) + ) + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + self.refresh_checksums(site) + with self.assertRaisesRegex( + validator.ValidationError, messages[name] + ): + self.validate(fixture) + + fixture, site = build_site(self.workspace, "custom-domain") + catalog_path = site / "site.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + paper = catalog["slots"][0] + alias = paper["artifacts"][1]["aliases"][0] + alias.update( + { + "path": "aliases/antidote.pdf", + "url": urljoin(fixture["base_url"], "aliases/antidote.pdf"), + "fallback_url": urljoin( + fixture["fallback_base_url"], "aliases/antidote.pdf" + ), + } + ) + write(site / "aliases/antidote.pdf", (site / "antidote.pdf").read_bytes()) + alias_route = next( + route for route in catalog["routes"] if route["path"] == "antidote.pdf" + ) + alias_route.update( + { + "path": "aliases/antidote.pdf", + "url": urljoin(fixture["base_url"], "aliases/antidote.pdf"), + "fallback_url": urljoin( + fixture["fallback_base_url"], "aliases/antidote.pdf" + ), + } + ) + catalog["routes"].sort( + key=lambda item: (item["path"], item["kind"], item["id"]) + ) + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + self.refresh_checksums(site) + with self.assertRaisesRegex( + validator.ValidationError, + "alias must be at site root|schema pattern", + ): + self.validate(fixture) + + child = self.workspace / "alias-route-drift" + child.mkdir() + fixture, site = build_site(child, "repository-subpath") + fixture["site_directory"] = f"alias-route-drift/{fixture['site_directory']}" + write(site / "other.bin", b"different public route bytes\n") + catalog_path = site / "site.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + alias_route = next( + route for route in catalog["routes"] if route["path"] == "antidote.pdf" + ) + alias_route["file"] = "other.bin" + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + self.refresh_checksums(site) + with self.assertRaisesRegex(validator.ValidationError, "alias has no compatible route"): + self.validate(fixture) + + child = self.workspace / "landing-alias" + child.mkdir() + fixture, site = build_site(child, "repository-subpath") + fixture["site_directory"] = f"landing-alias/{fixture['site_directory']}" + write(site / "paper.html", (site / "paper/index.html").read_bytes()) + catalog_path = site / "site.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + accessible = catalog["slots"][0]["artifacts"][0] + accessible["aliases"].append( + { + "fallback_url": None, + "path": "paper.html", + "url": urljoin(fixture["base_url"], "paper.html"), + } + ) + catalog["routes"].append( + { + "fallback_url": None, + "id": "paper:accessible-web", + "kind": "artifact-alias", + "path": "paper.html", + "url": urljoin(fixture["base_url"], "paper.html"), + } + ) + catalog["routes"].sort( + key=lambda item: (item["path"], item["kind"], item["id"]) + ) + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + self.refresh_checksums(site) + with self.assertRaisesRegex(validator.ValidationError, "incompatible route id"): + self.validate(fixture) + + def test_inventory_cap_and_sanitized_failure_evidence(self) -> None: + fixture, site = build_site(self.workspace, "repository-subpath") + original_limit = validator.MAX_SITE_FILES + validator.MAX_SITE_FILES = 2 + self.addCleanup(setattr, validator, "MAX_SITE_FILES", original_limit) + with self.assertRaisesRegex(validator.ValidationError, "entry-count|file-count"): + validator.inventory_files(site) + + validator.MAX_SITE_FILES = original_limit + catalog_path = site / "site.json" + catalog_path.write_text("{not-json\n", encoding="utf-8") + self.refresh_checksums(site) + evidence = self.workspace / ".relay/failure.json" + result = validator.main( + [ + "--workspace", + str(self.workspace), + "--site-directory", + fixture["site_directory"], + "--expected-base-url", + fixture["base_url"], + "--expected-source-revision", + REVISION, + "--evidence-output", + ".relay/failure.json", + ] + ) + self.assertEqual(result, 2) + failure = evidence.read_text(encoding="utf-8") + self.assertIn("publication-site-validation-failed", failure) + self.assertNotIn(str(self.workspace), failure) + self.assertNotIn("not-json", failure) + + def test_base_urls_reject_output_injection_and_ambiguous_paths(self) -> None: + for value in ( + "https://example.test/\nforged=value/", + "https://example.test/%2e%2e/", + "https://example.test/a//b/", + "https://example.test/a\\b/", + "https://localhost/", + "https://127.0.0.1/", + "https://example.test:8443/", + "https://internal/", + ): + with self.subTest(value=value): + with self.assertRaises(validator.ValidationError): + validator.normalize_base_url(value, "fixture") + + def test_symlink_and_protected_output_are_rejected(self) -> None: + fixture, site = build_site(self.workspace, "custom-domain") + outside = self.workspace / "outside.txt" + outside.write_text("private\n", encoding="utf-8") + (site / "linked.txt").symlink_to(outside) + self.refresh_checksums(site, skip_symlinks=True) + + with self.assertRaisesRegex(validator.ValidationError, "symbolic links"): + self.validate(fixture) + + protected = self.workspace / ".github" + shutil.copytree(site, protected, symlinks=True) + with self.assertRaisesRegex(validator.ValidationError, "protected root"): + validator.validate_publication_site( + workspace=self.workspace, + site_directory=".github", + expected_base_url=fixture["base_url"], + expected_source_revision=REVISION, + ) + + def test_incomplete_unsorted_and_mismatched_checksums_fail(self) -> None: + for mode, message in ( + ("incomplete", "incomplete"), + ("unsorted", "sorted"), + ("mismatch", "checksum mismatch"), + ): + with self.subTest(mode=mode): + child = self.workspace / mode + child.mkdir() + fixture, site = build_site(child, "repository-subpath") + fixture["site_directory"] = f"{mode}/{fixture['site_directory']}" + manifest = site / "SHA256SUMS" + lines = manifest.read_text(encoding="utf-8").splitlines() + if mode == "incomplete": + lines.pop() + elif mode == "unsorted": + lines[0], lines[1] = lines[1], lines[0] + else: + target = site / lines[0].split(" ", 1)[1] + target.write_bytes(target.read_bytes() + b"changed") + manifest.write_text("\n".join(lines) + "\n", encoding="utf-8") + with self.assertRaisesRegex(validator.ValidationError, message): + self.validate(fixture) + + child = self.workspace / "bounded-incomplete-diagnostic" + child.mkdir() + fixture, site = build_site(child, "repository-subpath") + fixture["site_directory"] = ( + f"bounded-incomplete-diagnostic/{fixture['site_directory']}" + ) + for index in range(50): + write(site / f"unlisted-{index:02d}.txt", "unlisted\n") + with self.assertRaises(validator.ValidationError) as caught: + self.validate(fixture) + message = str(caught.exception) + self.assertIn("missing count=50", message) + self.assertLess(len(message), 500) + + def test_required_route_base_and_revision_must_match(self) -> None: + fixture, _ = build_site(self.workspace, "custom-domain") + + for replacement, message in ( + ({"required_routes": ["missing/"]}, "missing required routes"), + ({"base_url": "https://other.example.test/"}, "canonical base URL"), + ): + changed = {**fixture, **replacement} + with self.subTest(replacement=replacement): + with self.assertRaisesRegex(validator.ValidationError, message): + self.validate(changed) + with self.assertRaisesRegex(validator.ValidationError, "source revision"): + validator.validate_publication_site( + workspace=self.workspace, + site_directory=fixture["site_directory"], + expected_base_url=fixture["base_url"], + expected_source_revision="c" * 40, + ) + + with self.assertRaisesRegex(validator.ValidationError, "fallback base URL"): + validator.validate_publication_site( + workspace=self.workspace, + site_directory=fixture["site_directory"], + expected_base_url=fixture["base_url"], + expected_source_revision=REVISION, + expected_fallback_base_url="https://wrong.example.test/", + ) + + fixture, _ = build_site(self.workspace, "repository-subpath") + with self.assertRaisesRegex(validator.ValidationError, "fallback base URL"): + validator.validate_publication_site( + workspace=self.workspace, + site_directory=fixture["site_directory"], + expected_base_url=fixture["base_url"], + expected_source_revision=REVISION, + expected_fallback_base_url="https://fallback.example.test/", + ) + + def test_non_finite_json_numbers_are_rejected(self) -> None: + for index, constant in enumerate(("NaN", "Infinity", "-Infinity")): + with self.subTest(constant=constant): + child = self.workspace / f"nonfinite-{index}" + child.mkdir() + fixture, site = build_site(child, "repository-subpath") + fixture["site_directory"] = ( + f"nonfinite-{index}/{fixture['site_directory']}" + ) + catalog_path = site / "site.json" + raw = catalog_path.read_text(encoding="utf-8") + catalog_path.write_text( + raw.replace( + '"schema":', + f'"extensions": {{"unsafe": {constant}}},\n "schema":', + 1, + ), + encoding="utf-8", + ) + self.refresh_checksums(site) + with self.assertRaisesRegex( + validator.ValidationError, "non-finite JSON number" + ): + self.validate(fixture) + + def test_optional_publication_metadata_matches_the_v1_contract(self) -> None: + def paper(catalog: dict) -> dict: + return next(slot for slot in catalog["slots"] if slot["id"] == "paper") + + mutations = { + "site-repository": lambda catalog: catalog["site"].update( + {"repository": "file:///etc/passwd"} + ), + "theme": lambda catalog: catalog["site"].update({"theme": "untrusted"}), + "source-repository": lambda catalog: paper(catalog)["source"].update( + {"repository": "not-a-url"} + ), + "identifiers": lambda catalog: paper(catalog).update( + {"identifiers": "bogus"} + ), + "release": lambda catalog: paper(catalog).update( + {"release": {"url": "http://localhost/release"}} + ), + "preview": lambda catalog: paper(catalog).update( + {"preview": "not-an-object"} + ), + "provenance": lambda catalog: paper(catalog).update( + {"provenance": "not-an-array"} + ), + "accessibility": lambda catalog: paper(catalog).update( + { + "accessibility": { + "summary": "Accessible", + "features": ["Tagged headings"], + "report": { + "label": "Report", + "url": "http://127.0.0.1/report", + }, + } + } + ), + "resource-reference": lambda catalog: paper(catalog).update( + { + "preview": { + "label": "Preview", + "resource_id": "missing", + "url": "https://example.github.io/publication/paper/", + "fallback_url": None, + "media_type": "text/html", + "path": "paper/index.html", + "bytes": 1, + "sha256": "f" * 64, + } + } + ), + "version-type": lambda catalog: paper(catalog).update( + {"version": 7} + ), + "superseded-by-type": lambda catalog: paper(catalog).update( + {"superseded_by": {"id": "later"}} + ), + "withdrawal-notice-type": lambda catalog: paper(catalog).update( + {"withdrawal_notice": ["not withdrawn"]} + ), + } + for name, mutate in mutations.items(): + with self.subTest(name=name): + child = self.workspace / f"metadata-{name}" + child.mkdir() + fixture, site = build_site(child, "repository-subpath") + fixture["site_directory"] = ( + f"metadata-{name}/{fixture['site_directory']}" + ) + catalog_path = site / "site.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + mutate(catalog) + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + self.refresh_checksums(site) + with self.assertRaises(validator.ValidationError): + self.validate(fixture) + + @staticmethod + def refresh_checksums(site: Path, *, skip_symlinks: bool = False) -> None: + """Refresh a deliberately mutated test site's inventory.""" + + paths = [ + path + for path in site.rglob("*") + if path.is_file() + and path.name != "SHA256SUMS" + and not (skip_symlinks and path.is_symlink()) + ] + lines = [ + f"{validator.sha256_file(path)} {path.relative_to(site).as_posix()}" + for path in sorted(paths, key=lambda item: item.relative_to(site).as_posix()) + ] + (site / "SHA256SUMS").write_text( + "\n".join(lines) + "\n", encoding="utf-8" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow_catalog.py b/tests/test_workflow_catalog.py index 5285318..d687a72 100644 --- a/tests/test_workflow_catalog.py +++ b/tests/test_workflow_catalog.py @@ -54,6 +54,10 @@ def test_catalog_inventories_every_current_workflow(self) -> None: if entry["audience"] == "reusable" }, { + "egohygiene/relay/.github/workflows/" + "publication-pages.yml@v1", + "egohygiene/relay/.github/workflows/" + "publication-review.yml@v1", "egohygiene/relay/.github/workflows/" "repository-intelligence.yml@v1" }, diff --git a/workflow-catalog.json b/workflow-catalog.json index 8702c8b..8e6bfa5 100644 --- a/workflow-catalog.json +++ b/workflow-catalog.json @@ -25,7 +25,7 @@ "audience": "internal", "status": "stable", "triggers": ["pull_request", "push", "workflow_dispatch"], - "permissions": {"contents": "read"}, + "permissions": {"actions": "read", "contents": "read"}, "timeout_minutes": 15, "concurrency": { "group": "relay-validation-${{ github.workflow }}-${{ github.ref }}", @@ -79,6 +79,272 @@ "outputs": [], "consumer_ref": null }, + { + "id": "publication-review", + "path": ".github/workflows/publication-review.yml", + "owner": "egohygiene/relay", + "purpose": "Validate and preserve exact caller-built publication bytes under a statically read-only permission ceiling.", + "audience": "reusable", + "status": "beta", + "triggers": ["workflow_call"], + "permissions": {"actions": "read", "contents": "read"}, + "timeout_minutes": 10, + "concurrency": { + "group": "relay-publication-review-${{ github.repository }}-${{ github.ref }}", + "cancel_in_progress": false + }, + "failure_semantics": { + "mode": "fail-closed", + "partial_success": false, + "retry": "fresh-run", + "description": "Invalid identity, bounds, catalog, routes, bytes, or checksums fail review; sanitized validation evidence remains available where safe." + }, + "inputs": [ + { + "name": "artifact-name", + "type": "string", + "required": true, + "description": "Complete static-site artifact uploaded by the caller-owned build job." + }, + { + "name": "expected-base-url", + "type": "string", + "required": true, + "description": "Canonical HTTPS base URL recorded in the public catalog." + }, + { + "name": "expected-source-revision", + "type": "string", + "required": true, + "description": "Full caller source commit recorded in the public catalog." + }, + { + "name": "required-routes", + "type": "string", + "required": false, + "default": "[]", + "description": "JSON array of required site-relative public routes." + }, + { + "name": "catalog-path", + "type": "string", + "required": false, + "default": "site.json", + "description": "Site-relative Beacon publication catalog path." + }, + { + "name": "checksum-path", + "type": "string", + "required": false, + "default": "SHA256SUMS", + "description": "Site-relative complete checksum inventory path." + }, + { + "name": "fallback-base-url", + "type": "string", + "required": false, + "default": "", + "description": "Optional technical fallback URL that must match the catalog when pinned." + }, + { + "name": "artifact-retention-days", + "type": "number", + "required": false, + "default": 30, + "description": "Retention for reviewed bytes and local validation evidence." + } + ], + "outputs": [ + { + "name": "review-artifact-name", + "type": "string", + "required": true, + "description": "Unique ordinary artifact containing the exact reviewed site bytes." + }, + { + "name": "evidence-artifact-name", + "type": "string", + "required": true, + "description": "Local validation evidence artifact name." + }, + { + "name": "file-count", + "type": "string", + "required": true, + "description": "Number of files in the reviewed site." + }, + { + "name": "site-tree-sha256", + "type": "string", + "required": true, + "description": "SHA-256 of the complete sorted SHA256SUMS bytes." + }, + { + "name": "total-bytes", + "type": "string", + "required": true, + "description": "Total byte count of the reviewed site." + } + ], + "consumer_ref": "egohygiene/relay/.github/workflows/publication-review.yml@v1" + }, + { + "id": "publication-pages", + "path": ".github/workflows/publication-pages.yml", + "owner": "egohygiene/relay", + "purpose": "Authorize a default-branch deployment, consume only the exact read-only reviewed site, and verify canonical and optional fallback HTTPS targets.", + "audience": "reusable", + "status": "beta", + "triggers": [ + "workflow_call" + ], + "permissions": { + "actions": "read", + "contents": "read", + "id-token": "write", + "pages": "write" + }, + "timeout_minutes": 20, + "concurrency": { + "group": "relay-publication-pages-${{ github.repository }}-${{ github.ref }}", + "cancel_in_progress": false + }, + "failure_semantics": { + "mode": "fail-closed", + "partial_success": false, + "retry": "fresh-run", + "description": "Unauthorized deployment requests, invalid local contracts, reviewed-byte drift, Pages failures, or bounded remote-proof failures stop the lifecycle; review evidence remains available where safe." + }, + "inputs": [ + { + "name": "artifact-name", + "type": "string", + "required": true, + "description": "Complete static-site artifact uploaded by the caller-owned build job." + }, + { + "name": "expected-base-url", + "type": "string", + "required": true, + "description": "Canonical HTTPS base URL recorded in the public catalog." + }, + { + "name": "expected-source-revision", + "type": "string", + "required": true, + "description": "Full caller source commit recorded in the public catalog." + }, + { + "name": "required-routes", + "type": "string", + "required": false, + "default": "[]", + "description": "JSON array of required site-relative public routes." + }, + { + "name": "catalog-path", + "type": "string", + "required": false, + "default": "site.json", + "description": "Site-relative Beacon publication catalog path." + }, + { + "name": "checksum-path", + "type": "string", + "required": false, + "default": "SHA256SUMS", + "description": "Site-relative complete checksum inventory path." + }, + { + "name": "fallback-base-url", + "type": "string", + "required": false, + "default": "", + "description": "Optional technical fallback URL that must match the catalog." + }, + { + "name": "verify-fallback", + "type": "boolean", + "required": false, + "default": false, + "description": "Verify every route and byte through the fallback URL." + }, + { + "name": "artifact-retention-days", + "type": "number", + "required": false, + "default": 30, + "description": "Retention for review and deployment-evidence artifacts." + }, + { + "name": "verification-attempts", + "type": "number", + "required": false, + "default": 8, + "description": "Maximum bounded attempts per remote resource." + }, + { + "name": "verification-retry-delay-seconds", + "type": "number", + "required": false, + "default": 5, + "description": "Bounded delay between remote verification attempts." + }, + { + "name": "verification-request-timeout-seconds", + "type": "number", + "required": false, + "default": 20, + "description": "Bounded application request timeout." + }, + { + "name": "verification-max-elapsed-seconds", + "type": "number", + "required": false, + "default": 300, + "description": "Bounded application-level remote verification deadline." + } + ], + "outputs": [ + { + "name": "review-artifact-name", + "type": "string", + "required": true, + "description": "Unique ordinary artifact containing the exact reviewed site bytes." + }, + { + "name": "evidence-artifact-name", + "type": "string", + "required": true, + "description": "Deployment verification evidence artifact name." + }, + { + "name": "page-url", + "type": "string", + "required": true, + "description": "Pages deployment URL." + }, + { + "name": "file-count", + "type": "string", + "required": true, + "description": "Number of files in the reviewed site." + }, + { + "name": "site-tree-sha256", + "type": "string", + "required": true, + "description": "SHA-256 of the complete sorted SHA256SUMS bytes." + }, + { + "name": "total-bytes", + "type": "string", + "required": true, + "description": "Total byte count of the reviewed site." + } + ], + "consumer_ref": "egohygiene/relay/.github/workflows/publication-pages.yml@v1" + }, { "id": "repository-intelligence", "path": ".github/workflows/repository-intelligence.yml",