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
17 changes: 17 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Configuration for actionlint (https://github.com/rhysd/actionlint).
paths:
.github/workflows/pack-release.yaml:
ignore:
# actionlint's type definitions for the `job` context (still true as
# of v1.7.12 / main) only include check_run_id, container, services,
# and status. GitHub added job.workflow_ref / job.workflow_sha /
# job.workflow_repository / job.workflow_file_path afterwards, so
# actionlint doesn't know about them yet and flags real, documented
# properties as undefined. Confirmed against GitHub's own docs
# (https://docs.github.com/en/actions/reference/contexts-reference#job-context),
# which show this exact pattern -- a reusable workflow checking out
# its own repo via job.workflow_repository/job.workflow_sha -- as the
# canonical example usage. Remove these two lines once actionlint
# ships support for the job workflow-identity properties.
- 'property "workflow_repository" is not defined in object type'
- 'property "workflow_sha" is not defined in object type'
88 changes: 88 additions & 0 deletions .github/scripts/pin_image_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Pin container image tags in a Helm values.yaml file to a release tag.

Used by pack-release.yaml to rewrite the working copy of a chart's
values.yaml so that its image tags point at the sha-<short> tag built
for the release commit, right before packaging. The change is made only
in the workflow's working directory; it is never committed back to the
source repository.

Usage:
python pin_image_tags.py <values-file> <sha-tag> <path1> [<path2> ...]

Each path is a dot-separated key path into the YAML document, e.g.
"operator.image.tag". The walk sets the final key in the path (the
"tag" leaf) to <sha-tag>, leaving every sibling key and comment intact.

Uses ruamel.yaml's round-trip mode so comments, quoting, and key order
in the original file are preserved. Fails loudly (non-zero exit) if any
segment of a path does not exist in the document -- a missing path is
almost always a typo'd values.yaml key and should stop the release
rather than silently no-op.
"""
import sys

from ruamel.yaml import YAML


def pin(values_file, sha_tag, paths):
"""Set the leaf key of each dotted path in values_file to sha_tag.

Args:
values_file: path to a values.yaml file, read and rewritten in place.
sha_tag: the value to assign to each path's leaf key, e.g. "sha-abc1234".
paths: dotted key paths such as "operator.image.tag". The last
segment is the leaf that gets set; everything before it is
walked as nested mapping keys.

Raises:
KeyError: if any segment of any path is missing from the document.
"""
yaml = YAML()
yaml.preserve_quotes = True

with open(values_file) as f:
data = yaml.load(f)

for dotted in paths:
keys = dotted.split(".")
node = data
for key in keys[:-1]:
if not isinstance(node, dict) or key not in node:
raise KeyError(
f"path '{dotted}' not found in {values_file}: "
f"no key '{key}'"
)
node = node[key]

leaf = keys[-1]
if not isinstance(node, dict) or leaf not in node:
raise KeyError(
f"path '{dotted}' not found in {values_file}: "
f"no key '{leaf}'"
)
node[leaf] = sha_tag

with open(values_file, "w") as f:
yaml.dump(data, f)


def main(argv):
if len(argv) < 4:
print(
"usage: pin_image_tags.py <values-file> <sha-tag> <path1> [<path2> ...]",
file=sys.stderr,
)
return 2

values_file, sha_tag, *paths = argv[1:]
try:
pin(values_file, sha_tag, paths)
except Exception as exc:
print(f"pin_image_tags: error: {exc}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
86 changes: 86 additions & 0 deletions .github/scripts/test_pin_image_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Tests for pin_image_tags.py.

Run with: python -m pytest .github/scripts/test_pin_image_tags.py -v
"""
import textwrap

import pytest
from ruamel.yaml import YAML

from pin_image_tags import pin

SAMPLE_VALUES = textwrap.dedent(
"""\
operator:
image:
repository: quay.io/nebari/llm-d-operator
tag: latest # bumped by CI on each release
replicas: 1

worker:
image:
repository: quay.io/nebari/llm-d-worker
tag: latest
"""
)


@pytest.fixture
def values_file(tmp_path):
path = tmp_path / "values.yaml"
path.write_text(SAMPLE_VALUES)
return path


def _load(values_file):
yaml = YAML()
with open(values_file) as f:
return yaml.load(f)


def test_pin_sets_tag(values_file):
pin(str(values_file), "sha-abc1234", ["operator.image.tag"])

data = _load(values_file)
assert data["operator"]["image"]["tag"] == "sha-abc1234"
# untouched path is left alone
assert data["worker"]["image"]["tag"] == "latest"


def test_pin_preserves_comment(values_file):
pin(str(values_file), "sha-abc1234", ["operator.image.tag"])

text = values_file.read_text()
# ruamel may re-flow the whitespace before an inline comment when the
# value's length changes, but the comment text itself must survive.
assert "tag: sha-abc1234" in text
assert "# bumped by CI on each release" in text


def test_pin_multiple_paths(values_file):
pin(str(values_file), "sha-def5678", ["operator.image.tag", "worker.image.tag"])

data = _load(values_file)
assert data["operator"]["image"]["tag"] == "sha-def5678"
assert data["worker"]["image"]["tag"] == "sha-def5678"


def test_missing_leaf_raises(values_file):
with pytest.raises(Exception):
pin(str(values_file), "sha-abc1234", ["operator.image.missing"])


def test_missing_intermediate_key_raises(values_file):
with pytest.raises(Exception):
pin(str(values_file), "sha-abc1234", ["nonexistent.image.tag"])


def test_original_file_unchanged_on_partial_success_is_not_guaranteed(values_file):
# Documents current behavior: a later path failing does not roll back
# earlier writes made in-memory before the dump. Since dump only
# happens once at the end, a failure means no dump happens at all and
# the file on disk is untouched.
original = values_file.read_text()
with pytest.raises(Exception):
pin(str(values_file), "sha-abc1234", ["operator.image.tag", "nonexistent.path.tag"])
assert values_file.read_text() == original
58 changes: 58 additions & 0 deletions .github/workflows/lint-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: lint-test

# CI for this repo's own reusable workflows and helper scripts.
#
# Scoped to the workflows introduced/maintained under this effort
# (pack-build-image.yaml, pack-release.yaml, and this file) rather than
# the whole .github/workflows/ directory: the other pre-existing
# workflows here (sync-issue-templates.yaml, sync-project-priority.yaml)
# already have unrelated actionlint findings (a floating, EOL
# actions/checkout@v3 pin and a few shellcheck info-level notes) that
# predate this change and are out of scope for it. Widen the file list
# below once those are cleaned up separately.

on:
pull_request:
push:
branches:
- main

jobs:
actionlint:
name: actionlint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Run actionlint
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo \
rhysd/actionlint@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 \
-color \
.github/workflows/pack-build-image.yaml \
.github/workflows/pack-release.yaml \
.github/workflows/lint-test.yaml

pytest:
name: pytest
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"

- name: Install dependencies
run: |
set -euo pipefail
python3 -m pip install "ruamel.yaml==0.19.1" pytest

- name: Run pytest
run: |
set -euo pipefail
python3 -m pytest .github/scripts/
98 changes: 98 additions & 0 deletions .github/workflows/pack-build-image.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: pack-build-image

on:
workflow_call:
inputs:
image:
description: "Image name suffix, e.g. 'operator' or 'frontend'."
required: true
type: string
context:
description: "Docker build context path."
required: true
type: string
dockerfile:
description: "Path to the Dockerfile. Defaults to '<context>/Dockerfile'."
required: false
type: string
default: ""
target:
description: "Optional multi-stage build target."
required: false
type: string
default: ""
platforms:
description: "Comma-separated platforms to build for."
required: false
type: string
default: "linux/amd64"
push:
description: "Whether to push the built image. Set false for pull_request builds."
required: false
type: boolean
default: true
secrets:
QUAY_TOKEN:
required: false

env:
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}/${{ inputs.image }}
QUAY_IMAGE: quay.io/nebari/${{ github.event.repository.name }}-${{ inputs.image }}

jobs:
build:
name: Build and push ${{ inputs.image }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0

- name: Short sha
id: sha
run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"

- name: Log in to GHCR
if: ${{ inputs.push }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Log in to Quay
if: ${{ inputs.push }}
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: quay.io
username: ${{ vars.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}

- name: Metadata (tags)
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.GHCR_IMAGE }}
${{ env.QUAY_IMAGE }}
tags: |
type=raw,value=sha-${{ steps.sha.outputs.short }}
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile || format('{0}/Dockerfile', inputs.context) }}
target: ${{ inputs.target }}
platforms: ${{ inputs.platforms }}
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.GHCR_IMAGE }}:cache
cache-to: ${{ inputs.push && format('type=registry,ref={0}:cache,mode=max', env.GHCR_IMAGE) || '' }}
Loading