Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 7 additions & 51 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Publish validation
name: Publish

on:
repository_dispatch:
Expand All @@ -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 <project> <tag> 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
Expand All @@ -71,32 +49,13 @@ 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
--registry projects.yml
--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 }}
Expand All @@ -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 }}
Expand All @@ -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
Expand All @@ -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 }}
Expand All @@ -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
71 changes: 22 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project> <tag> 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

Expand Down
9 changes: 3 additions & 6 deletions spikes/phase0/Dockerfile.tools → docker/tools.Dockerfile
Original file line number Diff line number Diff line change
@@ -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 \
Expand All @@ -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 []
27 changes: 10 additions & 17 deletions docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
50 changes: 21 additions & 29 deletions docs/docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project> <tag> 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.

Expand Down
24 changes: 0 additions & 24 deletions moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions scripts/lint-workflows.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading