Container and PyPI Workflows

github-webhook-server has built-in workflows for container images and Python package publishing. Once a repository is configured, the server reacts to pull requests, merge events, comment commands, and pushed tags to build images, validate Python packaging, publish to PyPI, and send Slack notifications.

Note: These workflows are implemented in the server itself, not in GitHub Actions. In this repository, there are no release or publish workflows under .github/workflows; the behavior comes from webhook handlers in the application code.

What Triggers What

Here is the high-level behavior:

  • pull_request opened or synchronized: runs the build-container check if container is configured, and runs the python-module-install check if pypi is configured.
  • issue_comment with /build-and-push-container: builds and pushes a PR-tagged image.
  • pull_request merged: builds and pushes a branch or stable image automatically.
  • push for refs/tags/*: publishes to PyPI if pypi is configured, and builds/pushes a release image if container.release: true.
  • Regular branch pushes: skipped for publishing.

A practical way to think about it is:

  • PRs are for validation.
  • Comment commands are for ad hoc pushes.
  • Merges publish branch or stable images.
  • Git tags publish releases.

Configuration

You can set these values in the central config.yaml or in a repository-local .github-webhook-server.yaml.

The relevant example from examples/.github-webhook-server.yaml looks like this:

slack-webhook-url: https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK

pypi:
  token: pypi-your-token-here

events:
  - push
  - pull_request
  - pull_request_review
  - pull_request_review_comment
  - pull_request_review_thread
  - issue_comment
  - check_run
  - status

container:
  username: your-registry-username
  password: your-registry-password
  repository: quay.io/your-org/your-repo
  tag: latest
  release: true
  build-args:
    - "BUILD_ARG=value"
  args:
    - "--platform=linux/amd64"

What these keys do:

  • events: if you explicitly list events, include push for tag releases, pull_request for PR checks and merge publishing, and issue_comment for manual /build-and-push-container.
  • container.username and container.password: credentials used for pushing images and cleaning up PR tags.
  • container.repository: the full image repository, such as ghcr.io/org/image or quay.io/org/image.
  • container.tag: the stable tag used when a PR is merged into main or master. If omitted, the code defaults to latest.
  • container.release: enables extra container publishing on git tag pushes.
  • container.build-args: converted into --build-arg flags for podman build.
  • container.args: passed through as extra podman build flags, useful for values like --platform=linux/amd64 or --format docker.
  • pypi.token: the PyPI API token used for twine upload.
  • slack-webhook-url: enables Slack notifications for successful PyPI publishes and container push outcomes.

Note: Configuration values are resolved in this order: repository .github-webhook-server.yaml, then the repository entry in central config.yaml, then top-level defaults in central config.yaml.

Note: If you use the server’s repository settings sync, enabling container adds the build-container check to required status checks, and enabling pypi adds python-module-install.

Container Workflow

Container builds use podman, not Docker. The server checks out a repository worktree, builds from the repository’s Dockerfile, and tags the image based on the event that triggered the build.

How image tags are chosen

The tag-selection logic in the code is:

if is_merged:
    pull_request_branch = pull_request.base.ref
    tag = (
        pull_request_branch
        if pull_request_branch not in (OTHER_MAIN_BRANCH, "main")
        else self.container_tag
    )
else:
    tag = f"pr-{pull_request.number}"

That produces these user-visible results:

  • PR validation builds use pr-<number>.
  • Manual /build-and-push-container pushes also use pr-<number>.
  • Merges into main or master use container.tag such as latest.
  • Merges into other base branches use the base branch name directly.
  • Tag pushes use the git tag directly, such as v1.2.3.

PR validation vs. manual push

When a PR is opened or updated, the server runs the build-container check. That check builds the image but does not push it to a registry.

If you want a pushed image for a PR, use the comment command:

/build-and-push-container

The welcome message generated by the server documents this command like this:

/build-and-push-container - Build and push container image (tagged with PR number)
Supports additional build arguments: /build-and-push-container --build-arg KEY=value

This manual command is separate from the build-container check. It is intended for “build me a test image now” workflows.

What happens on merge

When a PR is merged, the server automatically builds and pushes again.

  • Merge to main or master: pushes <container.repository>:<container.tag>.
  • Merge to another branch: pushes <container.repository>:<base-branch>.

Merged-PR builds also add --no-cache to the podman build command.

Note: container.release: true only controls tag-based release publishing. It does not disable the automatic image push that happens when a PR is merged.

What happens on tag pushes

If a tag push arrives and container.release: true is enabled, the server builds and pushes a release image tagged with that exact git tag.

Examples:

  • Git tag v1.2.3 becomes image tag v1.2.3
  • Git tag 2026.03.18 becomes image tag 2026.03.18

Regular branch pushes do not trigger container publishing.

Warning: Branch names and git tags become image tags as-is. Names like release/1.2 or release/v1.2.3 are valid git refs, but they may not be valid container tags in your registry. Use registry-safe names such as v1.2.3 for release tags.

Registry cleanup for PR images

When a PR is closed or merged, the server tries to delete the temporary pr-<number> tag from the registry.

  • For ghcr.io, it uses the GitHub Packages API.
  • For other registries such as Quay or Docker Hub, it uses regctl.

Warning: Use a fully qualified image repository such as ghcr.io/org/image or quay.io/org/image. If the registry host is missing, the server skips PR-tag cleanup because it cannot tell which registry to talk to.

Draft PRs and manual container pushes

By default, comment commands are blocked on draft PRs. The example config.yaml documents the draft allowlist like this:

# allow-commands-on-draft-prs:     # Or allow only specific commands:
#   - build-and-push-container
#   - retest

If you want /build-and-push-container to work on draft PRs, add it to allow-commands-on-draft-prs.

PyPI Workflow

Enabling pypi gives you two different behaviors:

  • A PR-time packaging check named python-module-install
  • A tag-time publish flow to PyPI

PR packaging check

On PR events, the server validates that the project can build as a wheel. The actual command in the code is:

cmd = "uvx pip wheel --no-cache-dir -w {worktree_path}/dist {worktree_path}"

This does not publish anything. It is just a fast packaging check that helps catch broken Python builds before release time.

Tag-based PyPI publishing

On a pushed git tag, the server checks out that tag and runs the publish flow. The key part of the implementation is:

rc, out, err = await run_command(
    command=f"uv {uv_cmd_dir} build --sdist --out-dir {_dist_dir}", log_prefix=self.log_prefix
)

commands: list[str] = [
    f"uvx {uv_cmd_dir} twine check {_dist_dir}/{tar_gz_file}",
    f"uvx {uv_cmd_dir} twine upload --username __token__ "
    f"--password {pypi_token} "
    f"{_dist_dir}/{tar_gz_file} --skip-existing",
]

In plain English, the server does this:

  1. Checks out the pushed tag.
  2. Builds a source distribution into a temporary pypi-dist directory.
  3. Runs twine check.
  4. Uploads to PyPI with twine upload --skip-existing.

Note: The release upload is an sdist only. The PR-time python-module-install check builds a wheel for validation, but the tag publish path uploads the source tarball.

Note: The uploaded package version comes from your package metadata in pyproject.toml, not from the git tag name. The server checks out the tag and builds whatever version is defined in the project at that commit.

If any part of the PyPI publish flow fails, the server creates a GitHub issue with a sanitized error summary instead of failing silently.

Tip: The upload uses --skip-existing, which makes repeated delivery of the same tag much easier to tolerate if the artifact is already on PyPI.

Tag-Based Releases

The server only needs a pushed git tag. It does not require a specific release tool.

That said, this repository’s own release flow is a good example of how to generate those tags. The relevant part of .release-it.json is:

{
  "git": {
    "commitMessage": "Release ${version}",
    "tag": true,
    "tagAnnotation": "Release ${version}",
    "push": true,
    "pushArgs": ["--follow-tags"],
    "changelog": "uv run scripts/generate_changelog.py ${from} ${to}"
  },
  "github": {
    "release": true,
    "releaseName": "Release ${version}"
  },
  "plugins": {
    "@release-it/bumper": {
      "in": "pyproject.toml",
      "out": { "file": "pyproject.toml", "path": "project.version" }
    }
  },
  "hooks": {
    "after:bump": "uv sync"
  }
}

That setup does four useful things:

  • Bumps project.version in pyproject.toml
  • Runs uv sync after the bump
  • Commits and pushes a release tag
  • Creates a GitHub release with generated changelog content

For github-webhook-server, the important detail is simple: once that tag is pushed to GitHub, the webhook server sees the push event and runs the PyPI and container release handlers.

Tip: You do not have to use release-it. Any workflow that pushes a tag to GitHub will trigger the same server-side release behavior, as long as the repository is configured to receive push events.

Slack Notifications

Slack is optional. If slack-webhook-url is not configured, the server skips notification delivery.

The messages are sent through a standard Slack incoming webhook with a simple JSON payload. The message text looks like this:

<repository> Version <tag> published to PYPI.
<owner/repo> New container for <image:tag> published.
<owner/repo> Failed to build and push <image:tag>.

What currently sends Slack notifications:

  • Successful PyPI publishes
  • Successful container pushes
  • Container push failures after a successful build step

What does not depend on Slack:

  • PR validation checks
  • Tag detection itself
  • PyPI upload logic
  • Container build logic

Slack is best treated as a notification layer, not the source of truth. Your real release state still lives in GitHub, your registry, and PyPI.

Practical Checklist

  1. Enable push, pull_request, and issue_comment events for the repository.
  2. Configure container if you want image builds and registry pushes.
  3. Configure pypi.token if you want tag-based PyPI publishing.
  4. Use a fully qualified image repository like ghcr.io/org/image.
  5. Use registry-safe git tags such as v1.2.3.
  6. Add slack-webhook-url if you want release notifications.
  7. If you want manual container pushes on draft PRs, allow build-and-push-container in allow-commands-on-draft-prs.