From cec804c2b15fb2491901c4d97501fec8c93e9482 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 22 Jul 2026 19:10:43 -0700 Subject: [PATCH] chore(publish): simplify publish path to project and tag Drop the typed confirmation phrases, the wired-in empty-staging recovery input, the duplicate unprivileged source proof, the dispatch-payload python validator, and hardcoded URL/prefix literals. The protected environments, explicit apply checkboxes, registry validation, remote verification, no-op replay, and clean-install checks remain the gates. Relocate the tools image out of the disposable spike directory. Co-Authored-By: Claude Fable 5 --- .github/workflows/publish.yml | 58 +------ README.md | 71 +++----- .../tools.Dockerfile | 9 +- docs/docs/index.md | 27 ++- docs/docs/operations.md | 50 +++--- moon.yml | 24 --- scripts/lint-workflows.sh | 3 +- scripts/phase5-source.sh | 155 ----------------- scripts/{phase5-publish.sh => publish.sh} | 45 ++--- scripts/validate_publish_event.py | 42 ----- scripts/validate_publish_event_test.py | 50 ------ spikes/phase0/build-repository.sh | 160 ------------------ 12 files changed, 74 insertions(+), 620 deletions(-) rename spikes/phase0/Dockerfile.tools => docker/tools.Dockerfile (70%) delete mode 100755 scripts/phase5-source.sh rename scripts/{phase5-publish.sh => publish.sh} (79%) delete mode 100644 scripts/validate_publish_event.py delete mode 100644 scripts/validate_publish_event_test.py delete mode 100755 spikes/phase0/build-repository.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 51f9f05..61fbf0e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish validation +name: Publish on: repository_dispatch: @@ -8,56 +8,34 @@ on: project: description: Registered project to publish required: true - default: incus-gh-runner type: string tag: - description: Stable v-prefixed release tag to validate + description: Stable v-prefixed release tag to publish required: true - default: v1.1.0 type: string apply_staging: description: Publish the verified release to the protected staging prefix required: true default: false type: boolean - empty_staging: - description: Empty only _staging/ before rebuilding it from the release - required: true - default: false - type: boolean - empty_staging_confirmation: - description: Type "empty _staging only" when empty_staging is selected - required: false - type: string apply_production: description: Publish to the protected production root after staging succeeds required: true default: false type: boolean - production_confirmation: - description: Type "publish to production" using the selected values - required: false - type: string permissions: {} jobs: validate: - name: Validate publish path without secrets + name: Validate the publish request without secrets runs-on: ubuntu-latest permissions: - attestations: read contents: read env: GOTOOLCHAIN: local PROJECT: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.project || inputs.project }} TAG: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.tag || inputs.tag }} - APPLY_STAGING: ${{ github.event_name == 'repository_dispatch' || inputs.apply_staging }} - EMPTY_STAGING: ${{ github.event_name == 'workflow_dispatch' && inputs.empty_staging }} - EMPTY_STAGING_CONFIRMATION: ${{ github.event_name == 'workflow_dispatch' && inputs.empty_staging_confirmation || '' }} - APPLY_PRODUCTION: ${{ github.event_name == 'repository_dispatch' || inputs.apply_production }} - PRODUCTION_CONFIRMATION: ${{ github.event_name == 'repository_dispatch' && format('publish {0} {1} to production', github.event.client_payload.project, github.event.client_payload.tag) || inputs.production_confirmation }} - MEIGMA_PACKAGES_GITHUB_TOKEN: ${{ github.token }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -71,10 +49,6 @@ jobs: version: 2026.6.14 cache: true - - name: Validate trusted dispatch payload shape - if: ${{ github.event_name == 'repository_dispatch' }} - run: python3 scripts/validate_publish_event.py "$GITHUB_EVENT_PATH" - - name: Validate request run: >- go run ./cmd/meigma-packages validate-request @@ -82,21 +56,6 @@ jobs: --project "$PROJECT" --tag "$TAG" - - name: Validate protected publication selections - run: | - expected_confirmation="publish $PROJECT $TAG to production" - if [[ "$EMPTY_STAGING" == true ]]; then - [[ "$APPLY_STAGING" == true ]] - [[ "$EMPTY_STAGING_CONFIRMATION" == 'empty _staging only' ]] - fi - if [[ "$APPLY_PRODUCTION" == true ]]; then - [[ "$APPLY_STAGING" == true ]] - [[ "$PRODUCTION_CONFIRMATION" == "$expected_confirmation" ]] - fi - - - name: Prove the real release source path - run: scripts/phase5-source.sh - staging: name: Publish and verify the protected staging prefix if: ${{ github.event_name == 'repository_dispatch' || inputs.apply_staging }} @@ -117,8 +76,6 @@ jobs: PUBLICATION_TARGET: staging PROJECT: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.project || inputs.project }} TAG: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.tag || inputs.tag }} - EMPTY_STAGING: ${{ github.event_name == 'workflow_dispatch' && inputs.empty_staging }} - EMPTY_STAGING_CONFIRMATION: ${{ github.event_name == 'workflow_dispatch' && inputs.empty_staging_confirmation || '' }} GPG_SIGNING_SUBKEY: ${{ secrets.GPG_SIGNING_SUBKEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} GPG_PRIMARY_FINGERPRINT: ${{ vars.GPG_PRIMARY_FINGERPRINT }} @@ -144,8 +101,8 @@ jobs: version: 2026.6.14 cache: true - - name: Publish and verify the real release in staging - run: scripts/phase5-publish.sh + - name: Publish and verify the release in staging + run: scripts/publish.sh production: name: Publish and verify the protected production root @@ -169,7 +126,6 @@ jobs: PUBLICATION_TARGET: production PROJECT: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.project || inputs.project }} TAG: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.tag || inputs.tag }} - PRODUCTION_CONFIRMATION: ${{ github.event_name == 'repository_dispatch' && format('publish {0} {1} to production', github.event.client_payload.project, github.event.client_payload.tag) || inputs.production_confirmation }} GPG_SIGNING_SUBKEY: ${{ secrets.GPG_SIGNING_SUBKEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} GPG_PRIMARY_FINGERPRINT: ${{ vars.GPG_PRIMARY_FINGERPRINT }} @@ -195,5 +151,5 @@ jobs: version: 2026.6.14 cache: true - - name: Publish and verify the real release in production - run: scripts/phase5-publish.sh + - name: Publish and verify the release in production + run: scripts/publish.sh diff --git a/README.md b/README.md index c4362f3..b701fef 100644 --- a/README.md +++ b/README.md @@ -55,59 +55,32 @@ go run ./cmd/meigma-packages --help go run ./cmd/meigma-packages --version ``` -## Real release source proof - -The opt-in Phase 5 proof downloads the selected registered project's exact -stable release from GitHub, verifies GitHub's asset digests and the pinned -release-workflow attestations, rebuilds the signed multi-architecture -repositories, and performs clean DEB and RPM installs on the current Docker -architecture: - -```sh -PROJECT=incus-gh-runner TAG=v1.1.0 moon run root:phase5-source-proof -``` - -`PROJECT` and `TAG` are required, so another registered exact stable release -uses the same proof without changing the script. The proof derives the expected -package version by removing exactly one leading `v` from the validated tag and -requires both package formats to report it. - -The source contract is checked into [`projects.yml`](projects.yml). The proof -uses GitHub and Docker but no publishing or production credentials. It does not -write to R2. - The entrypoint under `cmd/meigma-packages` remains thin. Cobra/Viper command construction lives under `internal/cli`, with `MEIGMA_PACKAGES_*` reserved as the environment-variable prefix for future configuration. -## Protected publication - -The `Publish validation` and manual `Rebuild validation` workflows exercise the -same fixture-backed proofs on GitHub-hosted runners. They use -`meigma-packages validate-request` to reject unknown projects, unsafe project -names, and invalid stable release tags before invoking the local proof. -Accepted publish tags have exactly the `vX.Y.Z` shape; prereleases, build -metadata, missing or repeated prefixes, and leading-zero variants fail closed. - -`Publish validation` also accepts the exact `publish-package` repository -dispatch event from a trusted consumer. Its payload is restricted to `project` -and `tag`; a valid dispatch always enters protected staging and production and -cannot request staging deletion. - -Both validation jobs are unprivileged. `Publish validation` can additionally -run the protected staging job with `apply_staging=true`. Selecting -`empty_staging=true` requires the exact `empty _staging only` confirmation and -rehearses recovery by emptying and rebuilding that prefix only. - -Production is a second protected job that can run only after staging succeeds. -It requires `apply_production=true` and the exact confirmation derived from the -validated selection: `publish to production`. Trusted dispatch -synthesizes that phrase internally; manual runs must supply it exactly. Root publication -preserves every `_staging/` object and rejects incomplete candidates before -remote access. Immutable package and content-addressed metadata objects receive -a one-year immutable cache policy; activation metadata, state, keys, and repo -configuration remain `no-store`. Production deletion is not an operator mode. -See the [operations boundary](docs/docs/operations.md) before dispatching it. +## Publication + +The `Publish` workflow runs on a trusted `publish-package` repository dispatch +from a registered consumer, or manually with a registered `project` and exact +stable `vX.Y.Z` `tag`. An unprivileged job validates the request against +[`projects.yml`](projects.yml) first; prereleases, build metadata, missing or +repeated prefixes, and leading-zero variants fail closed. + +The protected `staging` and `production` jobs then run `scripts/publish.sh`: +download the release from GitHub, verify asset digests and pinned +release-workflow attestations, rebuild the signed multi-architecture APT/RPM +tree, apply the ordered sync plan to R2, verify the remote bytes, repeat the +publish as a no-op, and install the package from the public URL on clean +Debian, Ubuntu, and Fedora containers. + +A trusted dispatch always publishes through staging to production. Manual runs +select `apply_staging` and `apply_production` explicitly; production only runs +after staging succeeds. Immutable package and content-addressed metadata +objects receive a one-year immutable cache policy; activation metadata, state, +keys, and repo configuration remain `no-store`. Production deletion is not an +operator mode. See the [operations boundary](docs/docs/operations.md) before +dispatching it. ## Documentation diff --git a/spikes/phase0/Dockerfile.tools b/docker/tools.Dockerfile similarity index 70% rename from spikes/phase0/Dockerfile.tools rename to docker/tools.Dockerfile index cb6e716..fa355a9 100644 --- a/spikes/phase0/Dockerfile.tools +++ b/docker/tools.Dockerfile @@ -1,6 +1,6 @@ FROM debian:13-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd -ARG PHASE0_UID=1000 +ARG TOOLS_UID=1000 RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ @@ -13,13 +13,10 @@ RUN apt-get update \ gnupg \ python3 \ rpm \ - shellcheck \ && rm -rf /var/lib/apt/lists/* -RUN useradd --uid "$PHASE0_UID" --gid nogroup --create-home phase0 +RUN useradd --uid "$TOOLS_UID" --gid nogroup --create-home tools -COPY build-repository.sh /usr/local/bin/build-repository - -USER phase0 +USER tools ENTRYPOINT [] diff --git a/docs/docs/index.md b/docs/docs/index.md index d6e77de..aac7996 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -28,20 +28,13 @@ go run ./cmd/meigma-packages --help The CLI is not distributed independently. Local development and GitHub Actions run it from this repository. -## Current proof surface - -- `root:phase5-source-proof` requires a registered project and exact stable tag - through `PROJECT` and `TAG`, then performs clean DEB and RPM installs without - publishing; `incus-gh-runner` `v1.1.0` is the current exercised example. -- `root:phase5-publish` is the secret-bearing protected entrypoint used to - publish, verify, repeat as a no-op, and install from staging or production. -- `root:workflow-check` validates workflow syntax and embedded and standalone - shell with pinned `actionlint` and ShellCheck. - -The publish workflow validates without secrets, then hands off to protected -staging and production. Manual runs require explicit inputs and exact -confirmation phrases derived from the validated project and tag. A trusted -consumer dispatch must contain exactly `project` and `tag`; it cannot select -deletion behavior, staging bypass, confirmation text, or R2 targeting. See [Install -packages](install.md) for the public repository commands and [Operations -boundary](operations.md) for the enforced scope and recovery behavior. +## Publication + +The `Publish` workflow validates the requested project and tag without +secrets, then hands off to the protected staging and production jobs, which +run `scripts/publish.sh` to rebuild, sign, apply, verify, and clean-install +the release. Manual runs select `apply_staging` and `apply_production` +explicitly; a trusted consumer dispatch carrying `project` and `tag` always +publishes through staging to production. See [Install packages](install.md) +for the public repository commands and [Operations boundary](operations.md) +for the enforced scope and recovery behavior. diff --git a/docs/docs/operations.md b/docs/docs/operations.md index 7f360b2..f5dddc3 100644 --- a/docs/docs/operations.md +++ b/docs/docs/operations.md @@ -11,37 +11,29 @@ description: What the read-only workflows and protected publication paths can do access, receives no secrets, and runs affected Moon tasks: Go checks, documentation, and workflow linting. -`Publish validation` accepts either a manual request or the exact -`publish-package` repository dispatch event. Both paths validate the registered -project and exact stable `vX.Y.Z` release tag and prove that same GitHub Release -source path without secrets. The consumer payload must contain exactly -`project` and `tag`; extra fields fail validation. +`Publish` accepts either a manual request or the exact `publish-package` +repository dispatch event. Both paths first validate the registered project +and exact stable `vX.Y.Z` release tag in an unprivileged job without secrets. Manual requests change no package repository state without the explicit -`apply_staging` input. A trusted consumer dispatch always continues through -protected staging and production after validation. It cannot request staging -deletion, skip staging, choose an R2 prefix, or supply either -protected-operation confirmation. - -`Publish validation` can additionally run a manually selected, protected -`staging` job after its read-only validation succeeds. It imports only the -signing subkey, independently revalidates and builds the selected registered -release, applies the ordered plan under `_staging/`, rehydrates and verifies R2, -repeats the publish as a no-op, and installs the package version derived from -the validated tag through the public hostname from clean Debian, Ubuntu, and -Fedora containers. - -The optional staging recovery rehearsal requires both `empty_staging=true` and -the exact `empty _staging only` phrase. It empties only `_staging/`, verifies -that operation, and immediately rebuilds the prefix from GitHub Releases. - -The protected `production` job runs only after staging succeeds and requires -the exact `publish to production` phrase derived from its -validated selection. Manual runs supply the phrase; trusted repository dispatch -synthesizes it internally and cannot override it. Production publishes at the -bucket root with separate credentials. Root sync deliberately excludes -`_staging/` from hydration, mutation, and verification, and rejects an -incomplete production candidate before making remote requests. A production +`apply_staging` and `apply_production` inputs. A trusted consumer dispatch +always continues through protected staging and production after validation; it +carries only `project` and `tag` and cannot skip staging or choose an R2 +prefix. + +The protected `staging` job imports only the signing subkey, independently +revalidates and builds the selected registered release, applies the ordered +plan under the staging prefix, rehydrates and verifies R2, repeats the publish +as a no-op, and installs the package version derived from the validated tag +through the public hostname from clean Debian, Ubuntu, and Fedora containers. +Because the applied sync plan converges on the verified desired state +(deletions last, verification before completion), recovering staging from any +bad or empty state is just another staging publish. + +The protected `production` job runs only after staging succeeds. Production +publishes at the bucket root with separate credentials. Root sync deliberately +excludes `_staging/` from hydration, mutation, and verification, and rejects +an incomplete production candidate before making remote requests. A production run may safely be a no-op. There is no operator selection that empties or deletes production independently of the verified desired-state plan. diff --git a/moon.yml b/moon.yml index 92cf683..fe68d90 100644 --- a/moon.yml +++ b/moon.yml @@ -66,35 +66,11 @@ tasks: options: cache: false - phase5-source-proof: - command: 'scripts/phase5-source.sh' - inputs: - - '@group(goSources)' - - 'projects.yml' - - 'scripts/phase5-source.sh' - - 'spikes/phase0/**/*' - options: - cache: false - runInCI: false - - phase5-publish: - command: 'scripts/phase5-publish.sh' - inputs: - - '@group(goSources)' - - 'projects.yml' - - 'scripts/phase5-publish.sh' - - 'spikes/phase0/**/*' - options: - cache: false - runInCI: false - workflow-check: command: 'scripts/lint-workflows.sh' inputs: - '.github/workflows/**/*' - 'scripts/*.sh' - - 'scripts/*.py' - - 'spikes/phase0/*.sh' - 'mise.toml' - 'mise.lock' options: diff --git a/scripts/lint-workflows.sh b/scripts/lint-workflows.sh index f98f54f..1d6a5e1 100755 --- a/scripts/lint-workflows.sh +++ b/scripts/lint-workflows.sh @@ -2,5 +2,4 @@ set -euo pipefail actionlint -shellcheck=shellcheck -shellcheck scripts/*.sh spikes/phase0/*.sh -python3 scripts/validate_publish_event_test.py +shellcheck scripts/*.sh diff --git a/scripts/phase5-source.sh b/scripts/phase5-source.sh deleted file mode 100755 index 54076bc..0000000 --- a/scripts/phase5-source.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -tools_image=meigma-packages-phase0-tools:local -debian_image='debian:13-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd' -fedora_image='fedora:44@sha256:6c75d5bf57cb0fa5aa4b92c6a83c86c791644496d9ac230de7711f5b8ec3b898' -run_id="phase5-source-$$" -network_name="$run_id" -server_name="$run_id-server" -work_dir=$(mktemp -d "${TMPDIR:-/tmp}/meigma-phase5.XXXXXX") - -cleanup() { - docker container rm --force "$server_name" >/dev/null 2>&1 || true - docker network rm "$network_name" >/dev/null 2>&1 || true - rm -rf -- "$work_dir" -} -trap cleanup EXIT - -command -v gh >/dev/null - -project=${PROJECT:?PROJECT is required} -tag=${TAG:?TAG is required} -validation_result=$(mise exec -- go run ./cmd/meigma-packages validate-request \ - --registry "$repo_root/projects.yml" \ - --project "$project" \ - --tag "$tag") -validated_project=$(jq -er '.project' <<<"$validation_result") -validated_tag=$(jq -er '.tag' <<<"$validation_result") -package_name=$(jq -er '.package_name' <<<"$validation_result") -package_version=$(jq -er '.package_version' <<<"$validation_result") -if [[ "$validated_project" != "$project" || "$validated_tag" != "$tag" || "$tag" != "v$package_version" ]]; then - echo 'validated source identity does not match the requested project and tag' >&2 - exit 2 -fi - -mise exec -- go run ./cmd/meigma-packages fetch-release \ - --registry "$repo_root/projects.yml" \ - --project "$validated_project" \ - --tag "$validated_tag" \ - --output "$work_dir/releases/$validated_tag" > "$work_dir/fetch-result.json" - -docker_arch=$(docker info --format '{{.Architecture}}') -case "$docker_arch" in - aarch64 | arm64) go_arch=arm64 ;; - x86_64 | amd64) go_arch=amd64 ;; - *) - echo "unsupported Docker architecture: $docker_arch" >&2 - exit 1 - ;; -esac - -docker build --quiet --tag "$tools_image" \ - --build-arg "PHASE0_UID=$(id -u)" \ - --file "$repo_root/spikes/phase0/Dockerfile.tools" \ - "$repo_root/spikes/phase0" >/dev/null - -docker run --rm --volume "$work_dir:/work" "$tools_image" \ - build-repository /work/signing-source 0.0.1 >/dev/null - -GOOS=linux GOARCH="$go_arch" CGO_ENABLED=0 \ - mise exec -- go build -trimpath -o "$work_dir/meigma-packages" ./cmd/meigma-packages -cp "$repo_root/projects.yml" "$work_dir/projects.yml" -signing_key=$(tr -d '\n' < "$work_dir/signing-source/signing-fingerprint.txt") - -docker run --rm --volume "$work_dir:/work" "$tools_image" \ - /work/meigma-packages rebuild-local \ - --registry /work/projects.yml \ - --project "$validated_project" \ - --releases /work/releases \ - --root /work/candidate \ - --gnupg-home /work/gnupg \ - --signing-key "$signing_key" \ - --base-url http://phase5-repo:8080 > "$work_dir/rebuild-result.json" - -docker run --rm --interactive \ - --volume "$work_dir:/work" \ - --env "PROJECT=$validated_project" \ - --env "TAG=$validated_tag" \ - "$tools_image" python3 - <<'PY' -import json -import os -from pathlib import Path - -root = Path('/work') -fetch = json.loads((root / 'fetch-result.json').read_text()) -manifest = json.loads((root / 'candidate/_state/manifest.json').read_text()) - -assert len(fetch['assets']) == 5 -assert fetch['project'] == os.environ['PROJECT'] -assert fetch['tag'] == os.environ['TAG'] -assert manifest['selected_versions'] == [os.environ['TAG']] -assert len(manifest['packages']) == 4 -assert { - (package['format'], package['repository_architecture']) - for package in manifest['packages'] -} == { - ('deb', 'amd64'), - ('deb', 'arm64'), - ('rpm', 'amd64'), - ('rpm', 'arm64'), -} -for architecture in ('amd64', 'arm64'): - assert ( - root - / f"candidate/apt/dists/stable/{os.environ['PROJECT']}" - / f'binary-{architecture}/Packages' - ).is_file() -for architecture in ('x86_64', 'aarch64'): - assert (root / f"candidate/rpm/{os.environ['PROJECT']}/{architecture}").is_dir() -PY - -docker network create "$network_name" >/dev/null -docker run --detach --rm \ - --name "$server_name" \ - --network "$network_name" \ - --network-alias phase5-repo \ - --volume "$work_dir/candidate:/srv:ro" \ - "$tools_image" \ - python3 -m http.server 8080 --directory /srv >/dev/null - -docker run --rm --network "$network_name" \ - --env "PROJECT=$validated_project" \ - --env "PACKAGE_NAME=$package_name" \ - --env "PACKAGE_VERSION=$package_version" \ - "$debian_image" sh -ceu ' - export DEBIAN_FRONTEND=noninteractive - apt-get update >/dev/null - apt-get install -y --no-install-recommends ca-certificates curl >/dev/null - install -d -m 0755 /etc/apt/keyrings - curl -fsS http://phase5-repo:8080/meigma.asc -o /etc/apt/keyrings/meigma.asc - cat > /etc/apt/sources.list.d/meigma.sources </dev/null - apt-get install -y --no-install-recommends "$PACKAGE_NAME" >/dev/null - test "$(dpkg-query --show --showformat="\${Version}" "$PACKAGE_NAME")" = "$PACKAGE_VERSION" -' - -docker run --rm --network "$network_name" \ - --env "PROJECT=$validated_project" \ - --env "PACKAGE_NAME=$package_name" \ - --env "PACKAGE_VERSION=$package_version" \ - "$fedora_image" sh -ceu ' - curl -fsS "http://phase5-repo:8080/rpm/$PROJECT/meigma.repo" \ - -o /etc/yum.repos.d/meigma.repo - dnf -q --refresh install -y "$PACKAGE_NAME" >/dev/null - test "$(rpm --query --queryformat "%{VERSION}" "$PACKAGE_NAME")" = "$PACKAGE_VERSION" -' - -echo "Phase 5 $validated_project $validated_tag release discovery, rebuild, and clean DEB/RPM installs passed." diff --git a/scripts/phase5-publish.sh b/scripts/publish.sh similarity index 79% rename from scripts/phase5-publish.sh rename to scripts/publish.sh index ffe3892..a96995d 100755 --- a/scripts/phase5-publish.sh +++ b/scripts/publish.sh @@ -2,11 +2,11 @@ set -euo pipefail repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -tools_image=meigma-packages-phase0-tools:local +tools_image=meigma-packages-tools:local debian_image='debian:13-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd' ubuntu_image='ubuntu:26.04@sha256:3131b4cc82a783df6c9df078f86e01819a13594b865c2cad47bd1bca2b7063bb' fedora_image='fedora:44@sha256:6c75d5bf57cb0fa5aa4b92c6a83c86c791644496d9ac230de7711f5b8ec3b898' -work_dir=$(mktemp -d "${TMPDIR:-/tmp}/meigma-phase5-publish.XXXXXX") +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/meigma-publish.XXXXXX") cleanup() { rm -rf -- "$work_dir" @@ -46,7 +46,6 @@ if [[ "$validated_project" != "$PROJECT" || "$validated_tag" != "$TAG" || "$TAG" echo 'validated publication identity does not match the requested project and tag' >&2 exit 2 fi -production_confirmation="publish $validated_project $validated_tag to production" apply_arguments=( --bucket "$R2_BUCKET" @@ -54,12 +53,8 @@ apply_arguments=( ) case "$PUBLICATION_TARGET" in staging) - if [[ "${R2_PREFIX:-}" != '_staging/' ]]; then - echo 'staging publication is confined to R2_PREFIX=_staging/' >&2 - exit 2 - fi - if [[ "$PUBLIC_BASE_URL" != 'https://pkgs.meigma.dev/_staging' ]]; then - echo 'staging publication requires the canonical staging public URL' >&2 + if [[ -z "${R2_PREFIX:-}" ]]; then + echo 'staging publication requires a non-empty R2_PREFIX' >&2 exit 2 fi apply_arguments+=(--prefix "$R2_PREFIX") @@ -69,14 +64,6 @@ case "$PUBLICATION_TARGET" in echo 'production publication requires an empty R2_PREFIX' >&2 exit 2 fi - if [[ "$PUBLIC_BASE_URL" != 'https://pkgs.meigma.dev' ]]; then - echo 'production publication requires the canonical public URL' >&2 - exit 2 - fi - if [[ "${PRODUCTION_CONFIRMATION:-}" != "$production_confirmation" ]]; then - echo 'production publication requires the exact confirmation phrase' >&2 - exit 2 - fi apply_arguments+=(--production-root) ;; *) @@ -102,9 +89,9 @@ case "$docker_arch" in esac docker build --quiet --tag "$tools_image" \ - --build-arg "PHASE0_UID=$(id -u)" \ - --file "$repo_root/spikes/phase0/Dockerfile.tools" \ - "$repo_root/spikes/phase0" >/dev/null + --build-arg "TOOLS_UID=$(id -u)" \ + --file "$repo_root/docker/tools.Dockerfile" \ + "$repo_root/docker" >/dev/null printf '%s' "$GPG_SIGNING_SUBKEY" > "$work_dir/signing-subkey.asc" chmod 0600 "$work_dir/signing-subkey.asc" @@ -147,18 +134,6 @@ docker run --rm \ --gpg-passphrase-file /work/gpg-passphrase \ --base-url "$PUBLIC_BASE_URL" > "$work_dir/rebuild-result.json" -if [[ "$PUBLICATION_TARGET" == staging && "${EMPTY_STAGING:-false}" == true ]]; then - if [[ "${EMPTY_STAGING_CONFIRMATION:-}" != 'empty _staging only' ]]; then - echo 'empty staging recovery requires the exact confirmation phrase' >&2 - exit 2 - fi - mkdir "$work_dir/empty" - mise exec -- go run ./cmd/meigma-packages apply-sync \ - --root "$work_dir/empty" \ - "${apply_arguments[@]}" > "$work_dir/empty-result.json" - jq -e '.verified == true' "$work_dir/empty-result.json" >/dev/null -fi - mise exec -- go run ./cmd/meigma-packages apply-sync \ --root "$work_dir/candidate" \ "${apply_arguments[@]}" > "$work_dir/apply-result.json" @@ -218,6 +193,6 @@ docker run --rm \ desired_state=$(jq -r '.desired_state_digest' "$work_dir/rebuild-result.json") actions=$(jq -r '.actions | length' "$work_dir/apply-result.json") -echo "Phase 5 $PUBLICATION_TARGET desired state: $desired_state" -echo "Phase 5 $PUBLICATION_TARGET ordered R2 actions: $actions" -echo "Phase 5 $PUBLICATION_TARGET publication, verification, no-op, and clean installs passed." +echo "$PUBLICATION_TARGET desired state: $desired_state" +echo "$PUBLICATION_TARGET ordered R2 actions: $actions" +echo "$PUBLICATION_TARGET publication, verification, no-op, and clean installs passed." diff --git a/scripts/validate_publish_event.py b/scripts/validate_publish_event.py deleted file mode 100644 index 4bf5d93..0000000 --- a/scripts/validate_publish_event.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -"""Validate the exact trusted repository-dispatch payload contract.""" - -import json -from pathlib import Path -import sys -from typing import Any - - -EXPECTED_FIELDS = {"project", "tag"} - - -def validate_event(event: Any) -> None: - """Require an object whose client payload contains only project and tag strings.""" - if not isinstance(event, dict): - raise ValueError("repository dispatch event must be an object") - payload = event.get("client_payload") - if not isinstance(payload, dict) or set(payload) != EXPECTED_FIELDS: - raise ValueError("repository dispatch payload must contain only project and tag") - for field in sorted(EXPECTED_FIELDS): - if not isinstance(payload[field], str) or payload[field] == "": - raise ValueError(f"repository dispatch {field} must be a non-empty string") - - -def main() -> int: - """Load and validate the GitHub event file named on the command line.""" - if len(sys.argv) != 2: - print("usage: validate_publish_event.py EVENT_PATH", file=sys.stderr) - return 2 - try: - event = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) - validate_event(event) - except (OSError, json.JSONDecodeError, ValueError) as error: - print(error, file=sys.stderr) - return 1 - - print("Repository dispatch payload is confined to project and tag.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/validate_publish_event_test.py b/scripts/validate_publish_event_test.py deleted file mode 100644 index 2e258e7..0000000 --- a/scripts/validate_publish_event_test.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for the trusted repository-dispatch payload boundary.""" - -import unittest - -from validate_publish_event import validate_event - - -class ValidatePublishEventTest(unittest.TestCase): - def test_accepts_incus_gh_runner_v1_1_0(self) -> None: - validate_event( - {"client_payload": {"project": "incus-gh-runner", "tag": "v1.1.0"}} - ) - - def test_rejects_privileged_controls(self) -> None: - privileged_fields = ( - "apply_production", - "confirmation", - "delete", - "empty_staging", - "r2_prefix", - "skip_staging", - ) - for field in privileged_fields: - with self.subTest(field=field), self.assertRaisesRegex( - ValueError, "only project and tag" - ): - validate_event( - { - "client_payload": { - "project": "incus-gh-runner", - "tag": "v1.1.0", - field: True, - } - } - ) - - def test_rejects_missing_or_non_string_identity(self) -> None: - invalid_payloads = ( - {"project": "incus-gh-runner"}, - {"project": "incus-gh-runner", "tag": 110}, - {"project": "", "tag": "v1.1.0"}, - ) - for payload in invalid_payloads: - with self.subTest(payload=payload), self.assertRaises(ValueError): - validate_event({"client_payload": payload}) - - -if __name__ == "__main__": - unittest.main() diff --git a/spikes/phase0/build-repository.sh b/spikes/phase0/build-repository.sh deleted file mode 100755 index b32dbdd..0000000 --- a/spikes/phase0/build-repository.sh +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root=${1:?repository output path is required} -version=${2:?package version is required} - -case "$repo_root" in - /work/*) ;; - *) - echo "repository output must be below /work" >&2 - exit 2 - ;; -esac - -export GNUPGHOME=/work/gnupg -key_uid='Meigma Phase 0 ' -package_name=meigma-phase0 -deb_arch=$(dpkg --print-architecture) - -rm -rf -- "$repo_root" -mkdir -p "$repo_root" "$GNUPGHOME" /work/build/payload -chmod 0700 "$GNUPGHOME" - -if ! gpg --batch --list-secret-keys "$key_uid" >/dev/null 2>&1; then - gpg --batch --pinentry-mode loopback --passphrase '' \ - --quick-generate-key "$key_uid" ed25519 cert 0 - primary_fingerprint=$(gpg --batch --with-colons --list-secret-keys "$key_uid" \ - | awk -F: '$1 == "fpr" { print $10; exit }') - gpg --batch --pinentry-mode loopback --passphrase '' \ - --quick-add-key "$primary_fingerprint" ed25519 sign 0 -fi - -primary_fingerprint=$(gpg --batch --with-colons --list-secret-keys "$key_uid" \ - | awk -F: '$1 == "fpr" { print $10; exit }') -signing_fingerprint=$(gpg --batch --with-colons --list-secret-keys "$key_uid" \ - | awk -F: '$1 == "ssb" { subkey = 1; next } subkey && $1 == "fpr" { print $10; exit }') - -if [[ -z "$primary_fingerprint" || -z "$signing_fingerprint" ]]; then - echo 'failed to resolve the Ed25519 primary or signing-subkey fingerprint' >&2 - exit 1 -fi - -cat > /work/build/payload/meigma-phase0 < "$deb_root/DEBIAN/control" < -Description: Disposable package fixture for the Meigma Phase 0 proof -EOF - -apt_root="$repo_root/apt" -binary_dir="dists/stable/phase0/binary-${deb_arch}" -mkdir -p "$apt_root/pool/phase0" "$apt_root/$binary_dir" -dpkg-deb --root-owner-group --build "$deb_root" \ - "$apt_root/pool/phase0/${package_name}_${version}_all.deb" >/dev/null - -rpm_top=/work/build/rpmbuild -rm -rf -- "$rpm_top" -mkdir -p "$rpm_top"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} -cat > "$rpm_top/SPECS/${package_name}.spec" </dev/null 2>&1 - -rpm_root="$repo_root/rpm/phase0" -mkdir -p "$rpm_root/noarch" -cp "$rpm_top/RPMS/noarch/${package_name}-${version}-1.noarch.rpm" "$rpm_root/noarch/" - -( - cd "$apt_root" - apt-ftparchive packages pool/phase0 > "$binary_dir/Packages" - gzip -9n -c "$binary_dir/Packages" > "$binary_dir/Packages.gz" - - for index in Packages Packages.gz; do - index_path="$binary_dir/$index" - for algorithm in SHA256 SHA512; do - case "$algorithm" in - SHA256) digest=$(sha256sum "$index_path" | awk '{ print $1 }') ;; - SHA512) digest=$(sha512sum "$index_path" | awk '{ print $1 }') ;; - esac - mkdir -p "$binary_dir/by-hash/$algorithm" - cp "$index_path" "$binary_dir/by-hash/$algorithm/$digest" - done - done - - apt-ftparchive \ - -o APT::FTPArchive::Release::Origin=Meigma \ - -o APT::FTPArchive::Release::Label=Meigma \ - -o APT::FTPArchive::Release::Suite=stable \ - -o APT::FTPArchive::Release::Codename=stable \ - -o APT::FTPArchive::Release::Architectures="$deb_arch" \ - -o APT::FTPArchive::Release::Components=phase0 \ - -o APT::FTPArchive::Release::Acquire-By-Hash=yes \ - -o APT::FTPArchive::Release::Description='Meigma Phase 0 repository' \ - release dists/stable > dists/stable/Release - - gpg --batch --yes --local-user "${signing_fingerprint}!" --armor --detach-sign \ - --output dists/stable/Release.gpg dists/stable/Release - gpg --batch --yes --local-user "${signing_fingerprint}!" --clearsign \ - --output dists/stable/InRelease dists/stable/Release -) - -createrepo_c "$rpm_root" >/dev/null -gpg --batch --yes --local-user "${signing_fingerprint}!" --armor --detach-sign \ - --output "$rpm_root/repodata/repomd.xml.asc" "$rpm_root/repodata/repomd.xml" - -cat > "$rpm_root/meigma.repo" <<'EOF' -[meigma-phase0] -name=Meigma Phase 0 -baseurl=http://phase0-repo:8080/rpm/phase0 -enabled=1 -repo_gpgcheck=1 -gpgcheck=0 -gpgkey=http://phase0-repo:8080/meigma.asc -EOF - -gpg --batch --armor --export "$primary_fingerprint" > "$repo_root/meigma.asc" -printf '%s\n' "$primary_fingerprint" > "$repo_root/primary-fingerprint.txt" -printf '%s\n' "$signing_fingerprint" > "$repo_root/signing-fingerprint.txt" -printf '%s\n' "$deb_arch" > "$repo_root/deb-architecture.txt" - -verify_home=/work/verify-gnupg -rm -rf -- "$verify_home" -mkdir -m 0700 "$verify_home" -GNUPGHOME="$verify_home" gpg --batch --import "$repo_root/meigma.asc" >/dev/null 2>&1 -GNUPGHOME="$verify_home" gpg --batch --verify \ - "$apt_root/dists/stable/InRelease" >/dev/null 2>&1 -GNUPGHOME="$verify_home" gpg --batch --verify \ - "$apt_root/dists/stable/Release.gpg" "$apt_root/dists/stable/Release" >/dev/null 2>&1 -GNUPGHOME="$verify_home" gpg --batch --verify \ - "$rpm_root/repodata/repomd.xml.asc" "$rpm_root/repodata/repomd.xml" >/dev/null 2>&1 - -printf 'built version=%s deb_arch=%s primary=%s signing=%s\n' \ - "$version" "$deb_arch" "$primary_fingerprint" "$signing_fingerprint"