Skip to content
Open
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
8 changes: 8 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# SPDX-FileCopyrightText: 2026 OpenCHAMI a Series of LF Projects, LLC
# SPDX-License-Identifier: MIT

paths:
.github/workflows/publish-rpm-repository.yml:
ignore:
- 'property "workflow_repository" is not defined in object type'
- 'property "workflow_sha" is not defined in object type'
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@ jobs:
- uses: actions/checkout@v6.0.3
- name: actionlint
uses: raven-actions/actionlint@v2.1.2

test-rpm-repository-publisher:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- name: Exercise RPM repository publisher
run: |
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
./tests/test-publish-rpm-repository.sh
150 changes: 150 additions & 0 deletions .github/workflows/publish-rpm-repository.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# SPDX-FileCopyrightText: 2026 OpenCHAMI a Series of LF Projects, LLC
# SPDX-License-Identifier: MIT

name: Publish RPM repository
run-name: Publish RPM repository for ${{ github.ref }}

on:
workflow_call:
inputs:
artifact-name-signed-rpms:
description: 'Name of the signed RPM artifact to publish'
type: string
default: 'rpms-signed'
required: false
artifact-name-package-signing-keys:
description: 'Name of the certified ephemeral package public-key artifact'
type: string
default: 'public-keys'
required: false
repository-path:
description: 'Path below the bucket, for example stable/el9/x86_64'
type: string
required: true
s3-bucket:
description: 'Destination S3 bucket'
type: string
required: true
aws-region:
description: 'AWS region containing the destination bucket'
type: string
required: true
aws-role-arn:
description: 'GitHub OIDC publisher role ARN'
type: string
required: true
aws-account-id:
description: 'Expected AWS account ID for confused-deputy protection'
type: string
required: true
cloudfront-distribution-id:
description: 'CloudFront distribution to invalidate after publication'
type: string
required: false
public-base-url:
description: 'Public repository base URL, for example https://rpm.openchami.org'
type: string
required: false
metadata-signing-key-fingerprint:
description: 'Full fingerprint of the durable repository metadata signing key'
type: string
required: true
package-master-fingerprint:
description: 'Full fingerprint of the trusted package-signing master key'
type: string
required: true
repository-key-filename:
description: 'Published filename for the metadata signing public key'
type: string
default: 'RPM-GPG-KEY-OpenCHAMI'
required: false
deployment-environment:
description: 'GitHub Environment containing publication protections and key secrets'
type: string
default: 'rpm-publish'
required: false
dry-run:
description: 'Build and verify repository metadata without accessing AWS'
type: boolean
default: false
required: false
outputs:
repository-url:
description: 'Published repository URL'
value: ${{ jobs.publish.outputs.repository-url }}
package-count:
description: 'Number of packages in the generated repository'
value: ${{ jobs.publish.outputs.package-count }}
repomd-sha256:
description: 'SHA-256 digest of the committed repomd.xml'
value: ${{ jobs.publish.outputs.repomd-sha256 }}
invalidation-id:
description: 'CloudFront invalidation ID, when requested'
value: ${{ jobs.publish.outputs.invalidation-id }}

permissions:
contents: read
id-token: write

concurrency:
group: rpm-repository-${{ inputs.s3-bucket }}-${{ inputs.repository-path }}
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
environment: ${{ inputs.deployment-environment }}
outputs:
repository-url: ${{ steps.publish.outputs.repository-url }}
package-count: ${{ steps.publish.outputs.package-count }}
repomd-sha256: ${{ steps.publish.outputs.repomd-sha256 }}
invalidation-id: ${{ steps.publish.outputs.invalidation-id }}
steps:
- name: Checkout publisher action at the reusable workflow commit
uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .openchami-actions
persist-credentials: false

- name: Download signed RPM artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ inputs.artifact-name-signed-rpms }}
path: dist/rpms

- name: Download package signing keys
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ inputs.artifact-name-package-signing-keys }}
path: dist/package-keys

- name: Configure AWS credentials
if: ${{ !inputs.dry-run }}
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
aws-region: ${{ inputs.aws-region }}
role-to-assume: ${{ inputs.aws-role-arn }}
role-session-name: OpenCHAMI-RPM-${{ github.run_id }}
allowed-account-ids: ${{ inputs.aws-account-id }}
unset-current-credentials: true

- name: Publish repository
id: publish
uses: ./.openchami-actions/actions/publish-rpm-repository
with:
rpm-path: dist/rpms
repository-path: ${{ inputs.repository-path }}
s3-bucket: ${{ inputs.s3-bucket }}
cloudfront-distribution-id: ${{ inputs.cloudfront-distribution-id }}
public-base-url: ${{ inputs.public-base-url }}
package-signing-keys-path: dist/package-keys
package-master-public-key-asc: ${{ secrets.MASTER_PUBLIC_ASC }}
package-master-fingerprint: ${{ inputs.package-master-fingerprint }}
authorized-package-repo-fingerprints: ${{ vars.PACKAGE_REPO_CERT_FINGERPRINTS }}
metadata-signing-key-armored-b64: ${{ secrets.RPM_REPOSITORY_SIGNING_KEY_B64 }}
metadata-signing-key-fingerprint: ${{ inputs.metadata-signing-key-fingerprint }}
metadata-signing-key-passphrase: ${{ secrets.RPM_REPOSITORY_SIGNING_KEY_PASSPHRASE }}
repository-key-filename: ${{ inputs.repository-key-filename }}
dry-run: ${{ inputs.dry-run }}
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ Reusable GitHub Actions for CI/CD.
- `actions/gpg-sign-rpm`: RPM signing with ephemeral keys
- `actions/gpg-check-key-expiration`: Fails CI if a signing key is expired or expiring soon
- `actions/gpg-verify-trust-chain`: Verifies the master/repo-cert/ephemeral trust chain and optionally checksigs RPMs
- `actions/publish-rpm-repository`: Generates durable-key-signed repository metadata and safely publishes RPM repositories to S3
- `.github/workflows/go-build-release.yml`: Reusable workflow for GoReleaser builds
- `.github/workflows/docker-build-release.yml`: Reusable workflow for multi-arch container image builds
- `.github/workflows/build-publish-container-goreleaser.yml`: Builds and publishes a container image via GoReleaser
- `.github/workflows/build-rpm-quadlet.yml`: Builds a caller repo's podman quadlet RPM
- `.github/workflows/gpg-sign-artifacts.yml`: Signs unsigned RPM artifacts with a per-run ephemeral key
- `.github/workflows/validate-rpm-quadlet.yml`: Validates a signed quadlet RPM's installed file list
- `.github/workflows/release-signed-artifacts.yml`: Publishes a GitHub Release with signed RPMs and public keys
- `.github/workflows/publish-rpm-repository.yml`: Publishes aggregated RPMs through S3 and CloudFront using GitHub OIDC
- `.github/workflows/lint-workflows.yml`: Reusable workflow that lints workflow files (actionlint + zizmor)
- `.github/workflows/govulncheck.yml`: Reusable workflow that scans Go modules for known CVEs
- `.github/workflows/dependency-review.yml`: Reusable workflow that gates PRs introducing CVE-flagged deps
Expand Down Expand Up @@ -211,6 +213,36 @@ jobs:
uses: OpenCHAMI/github-actions/.github/workflows/release-signed-artifacts.yml@v3.5
```

### publish-rpm-repository (Reusable Workflow)

Aggregates signed RPM artifacts into a DNF/YUM repository, signs `repomd.xml`
with a durable repository metadata key, publishes to S3 using GitHub OIDC, and
invalidates only mutable CloudFront metadata paths. The calling repository's
`rpm-publish` GitHub Environment supplies the durable signing-key secrets and
can enforce required reviewers.

```yaml
jobs:
publish:
uses: OpenCHAMI/github-actions/.github/workflows/publish-rpm-repository.yml@v3.6
with:
repository-path: stable/el9/x86_64
s3-bucket: ${{ vars.RPM_REPOSITORY_BUCKET }}
aws-region: us-east-1
aws-role-arn: ${{ vars.RPM_REPOSITORY_PUBLISHER_ROLE_ARN }}
aws-account-id: ${{ vars.AWS_ACCOUNT_ID }}
cloudfront-distribution-id: ${{ vars.RPM_REPOSITORY_DISTRIBUTION_ID }}
public-base-url: https://rpm.openchami.org
package-master-fingerprint: ${{ vars.MASTER_FPR }}
metadata-signing-key-fingerprint: ${{ vars.RPM_REPOSITORY_SIGNING_KEY_FINGERPRINT }}
```

The service repositories continue signing RPM packages with certified ephemeral
keys. Publication verifies those chains and emits a rotating DNF package-key
bundle. Only repository metadata uses the durable key. See the
[action documentation](actions/publish-rpm-repository/README.md) for publication
ordering, key handling, and client trust requirements.

## Actions

### gpg-ephemeral-key (Deprecated - use gpg-configure-release-keys)
Expand All @@ -228,6 +260,12 @@ Fails CI if the provided signing key is expired or expiring within a threshold.
### gpg-verify-trust-chain
Verifies the master/repo-cert/ephemeral trust chain and optionally checksigs RPMs. See the [action README](actions/gpg-verify-trust-chain/README.md).

### publish-rpm-repository

Builds repository metadata from signed RPMs, signs `repomd.xml` with the durable
repository metadata key, and publishes immutable packages before the metadata
commit point. See the [action README](actions/publish-rpm-repository/README.md).

## Security Model

Trust chain: `Ephemeral Key <- Repo Cert Key <- Offline Master Key`.
Expand Down
142 changes: 142 additions & 0 deletions actions/publish-rpm-repository/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<!--
SPDX-FileCopyrightText: 2026 OpenCHAMI a Series of LF Projects, LLC
SPDX-License-Identifier: MIT
-->

# Publish RPM Repository Action

Builds DNF/YUM repository metadata for signed RPMs, signs `repomd.xml` with a
durable repository metadata key, and safely publishes the repository to S3.

Each service repository signs its RPMs with a certified per-run ephemeral key
before the release workflow aggregates them. This action authenticates every
ephemeral key through the trusted master and repository certification-key chain,
then verifies every incoming and previously published RPM before signing the
repository metadata.

## Publication model

The action restores previously published immutable RPMs, adds the incoming RPMs,
and regenerates metadata for the complete package set. It never deletes packages
or metadata from S3. Publication occurs in this order:

1. Immutable RPMs under `Packages/`.
2. Content-addressed files under `repodata/`.
3. Immutable authenticated package-key archives.
4. Rotating package-key bundle and durable metadata signing public key.
5. `repodata/repomd.xml.asc`.
6. `repodata/repomd.xml` as the final metadata pointer.
7. A CloudFront invalidation limited to metadata and rotating public keys.

S3 cannot atomically replace `repomd.xml` and its detached signature. Publishing
the signature first makes the short update interval fail closed: a client may
temporarily reject mismatched metadata and retry, but it cannot accept unsigned
or partially published metadata.

The reusable workflow serializes publishers for a repository path. Production
publication should only be invoked by the central `OpenCHAMI/release` repository
so publishers in separate repositories cannot race.

## Inputs

| Input | Required | Default | Description |
|---|---:|---|---|
| `rpm-path` | Yes | | File or directory containing signed RPMs |
| `repository-path` | Yes | | Bucket prefix such as `stable/el9/x86_64` |
| `s3-bucket` | For publication | | Destination bucket |
| `cloudfront-distribution-id` | No | | Distribution to invalidate |
| `public-base-url` | No | | Public base URL such as `https://rpm.openchami.org` |
| `package-signing-keys-path` | Yes | | Directory containing paired `repo-cert.pub.asc` and `ephemeral.pub.asc` files |
| `package-master-public-key-asc` | Yes | | Trusted package-signing master public key |
| `package-master-fingerprint` | Yes | | Full trusted package-signing master fingerprint |
| `authorized-package-repo-fingerprints` | Yes | | Current repo-cert fingerprints allowed to certify new ephemeral keys |
| `package-key-bundle-filename` | No | `RPM-GPG-KEY-OpenCHAMI-Packages` | Rotating authenticated package-key bundle |
| `metadata-signing-key-armored-b64` | Yes | | Base64-encoded armored durable secret key |
| `metadata-signing-key-fingerprint` | Yes | | Full durable signing-key fingerprint |
| `metadata-signing-key-passphrase` | No | | Secret-key passphrase |
| `repository-key-filename` | No | `RPM-GPG-KEY-OpenCHAMI` | Public key filename |
| `require-signed-rpms` | No | `true` | Reject RPMs without package signatures |
| `dry-run` | No | `false` | Build and verify locally without AWS |

## Outputs

| Output | Description |
|---|---|
| `repository-directory` | Local complete repository directory |
| `repository-url` | Public URL, S3 URL, or dry-run file URL |
| `package-count` | Number of RPMs in the generated repository |
| `repomd-sha256` | SHA-256 digest of `repomd.xml` |
| `invalidation-id` | CloudFront invalidation ID when requested |

## Direct usage

The caller must configure short-lived AWS credentials before invoking the action.

```yaml
permissions:
contents: read
id-token: write

steps:
- uses: aws-actions/configure-aws-credentials@v6.2.3
with:
aws-region: us-east-1
role-to-assume: ${{ vars.RPM_REPOSITORY_PUBLISHER_ROLE_ARN }}
allowed-account-ids: ${{ vars.AWS_ACCOUNT_ID }}

- uses: OpenCHAMI/github-actions/actions/publish-rpm-repository@v3.6
with:
rpm-path: dist/rpms
repository-path: stable/el9/x86_64
s3-bucket: ${{ vars.RPM_REPOSITORY_BUCKET }}
cloudfront-distribution-id: ${{ vars.RPM_REPOSITORY_DISTRIBUTION_ID }}
public-base-url: https://rpm.openchami.org
package-signing-keys-path: dist/package-keys
package-master-public-key-asc: ${{ secrets.MASTER_PUBLIC_ASC }}
package-master-fingerprint: ${{ vars.MASTER_FPR }}
authorized-package-repo-fingerprints: ${{ vars.PACKAGE_REPO_CERT_FINGERPRINTS }}
metadata-signing-key-armored-b64: ${{ secrets.RPM_REPOSITORY_SIGNING_KEY_B64 }}
metadata-signing-key-fingerprint: ${{ vars.RPM_REPOSITORY_SIGNING_KEY_FINGERPRINT }}
metadata-signing-key-passphrase: ${{ secrets.RPM_REPOSITORY_SIGNING_KEY_PASSPHRASE }}
```

For production, prefer the repository's `publish-rpm-repository.yml` reusable
workflow, which supplies OIDC permissions, GitHub Environment protections, and
concurrency controls.

## Durable key handling

Store these secrets in the protected `rpm-publish` GitHub Environment of the
calling repository:

- `RPM_REPOSITORY_SIGNING_KEY_B64`
- `RPM_REPOSITORY_SIGNING_KEY_PASSPHRASE`
- `MASTER_PUBLIC_ASC`

Store the non-secret fingerprint and AWS identifiers as environment variables.
The action imports the durable secret key into an isolated temporary
`GNUPGHOME`, exports only its public key into the repository, and shreds the
temporary keyring at exit.

`PACKAGE_REPO_CERT_FINGERPRINTS` is a comma, whitespace, or newline-separated
allowlist maintained in the protected environment. Historical repo-cert keys
remain archived so old RPM signatures can be verified, but only keys in this
current allowlist may certify newly submitted ephemeral package keys. Remove a
retired or compromised repo-cert fingerprint before the next publication.

Clients should configure both `gpgcheck=1` for ephemeral package signatures and
`repo_gpgcheck=1` for the durable `repomd.xml` signature. The `.repo` file must
list both published keys:

```ini
gpgkey=https://rpm.openchami.org/stable/el9/x86_64/RPM-GPG-KEY-OpenCHAMI-Packages
https://rpm.openchami.org/stable/el9/x86_64/RPM-GPG-KEY-OpenCHAMI
```

The package bundle contains only ephemeral public keys whose certification
chains were verified during publication. DNF imports those concrete keys rather
than being expected to discover the OpenPGP certification chain itself.

## License

MIT
Loading
Loading