Skip to content

Use self-repository syntax for composite actions #138

Use self-repository syntax for composite actions

Use self-repository syntax for composite actions #138

Workflow file for this run

name: CI
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Many color libraries just need this variable to be set to any value.
# Set it to 3 to support 8-bit color graphics (256 colors per channel)
# for libraries that care about the value set.
FORCE_COLOR: 3
jobs:
lint:
name: Format
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.x"
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
- name: Run Pylint
run: uvx nox -s pylint -- --output-format=github
checks:
name: Check Python ${{ matrix.python-version }} on ${{ matrix.runs-on }}
runs-on: ${{ matrix.runs-on }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.14"]
runs-on: [ubuntu-latest, windows-latest, macos-latest, macos-15-intel]
include:
- python-version: "pypy-3.11"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Install package
run: uv sync
- name: Test package
run: >-
uv run pytest -ra --cov --cov-report=xml --cov-report=term
--junitxml=junit.xml -o junit_family=legacy --durations=20
- name: Upload coverage report
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
# The e2e-* jobs exercise the reusable notify and bump-images workflows
# end-to-end on every PR: notify runs in --dry-run mode against fixture
# deploy targets with dummy credentials, and bump-images consumes freshly
# generated client payloads against fixture manifests in tests/e2e/.
e2e-payload:
name: E2E build client payloads
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
commit_payload: ${{ steps.payloads.outputs.commit_payload }}
pr_payload: ${{ steps.payloads.outputs.pr_payload }}
tag: ${{ steps.payloads.outputs.tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Install odp-releaser from this checkout
run: uv tool install .
- name: Build client payloads
id: payloads
env:
DIGEST: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
run: |
TAG="e2e-${GITHUB_SHA:0:7}"
COMMIT_PAYLOAD=$(odp-releaser test make-payload \
ghcr.io/gulfofmaine/odp-releaser-e2e-commit "$TAG" "$DIGEST" \
--github-event-name workflow_dispatch)
PR_PAYLOAD=$(odp-releaser test make-payload \
ghcr.io/gulfofmaine/odp-releaser-e2e-pr "$TAG" "$DIGEST" \
--github-event-name workflow_dispatch)
{
printf 'commit_payload=%s\n' "$COMMIT_PAYLOAD"
printf 'pr_payload=%s\n' "$PR_PAYLOAD"
printf 'tag=%s\n' "$TAG"
} >> "$GITHUB_OUTPUT"
e2e-notify:
name: E2E notify (dry run)
permissions:
contents: read # notify.yml checks out this repo to read the deploy targets
pull-requests: read # match notify.yml's declared job permissions
uses: ./.github/workflows/notify.yml
with:
image_name: ghcr.io/gulfofmaine/odp-releaser-e2e-commit
tag: e2e
digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
deploy_targets_path: tests/e2e/deploy_targets.yaml
event_name: workflow_dispatch
dry_run: true
verbosity: 2
secrets:
# Dummy values: --dry-run only checks credential *presence* per target
# owner (resolve_app_credentials); nothing is minted or sent.
dispatch_app_id: "000000"
dispatch_app_private_key: e2e-dummy-not-a-real-key
dispatch_apps:
'{"odp-e2e-org": {"app_id": "111111", "private_key":
"e2e-dummy-not-a-real-key"}}'
e2e-bump-commit:
name: E2E bump-images (commit mode)
needs: [e2e-payload]
permissions:
contents: write # match bump-images.yml's declared job permissions; dry_run never pushes
pull-requests: write # match bump-images.yml's declared job permissions; dry_run opens no PR
id-token: write # match bump-images.yml's declared job permissions; no sync is configured here
uses: ./.github/workflows/bump-images.yml
with:
config_path: tests/e2e/image_manifest.yaml
client_payload: ${{ needs.e2e-payload.outputs.commit_payload }}
dry_run: true
verbosity: 2
e2e-bump-pr:
name: E2E bump-images (pull_request mode)
needs: [e2e-payload]
permissions:
contents: write # match bump-images.yml's declared job permissions; dry_run never pushes
pull-requests: write # match bump-images.yml's declared job permissions; dry_run opens no PR
id-token: write # match bump-images.yml's declared job permissions; no sync is configured here
uses: ./.github/workflows/bump-images.yml
with:
config_path: tests/e2e/image_manifest.yaml
client_payload: ${{ needs.e2e-payload.outputs.pr_payload }}
dry_run: true
verbosity: 2
e2e-action-self-install:
name: E2E composite action installs the CLI itself
# The reusable workflows install the CLI before calling the composite
# actions, so they only ever exercise the no-op branch of `install`. This
# job is the other half of the contract a caller gets: `bump_images` alone,
# with no install step and no uv on the PATH, has to resolve
# `$/.github/actions/install` and install the CLI for itself.
needs: [e2e-payload]
runs-on: ubuntu-latest
permissions:
contents: read # dry_run writes nothing and pushes nothing
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Bump images
id: bump
uses: ./.github/actions/bump_images
with:
config_path: tests/e2e/image_manifest.yaml
client_payload: ${{ needs.e2e-payload.outputs.commit_payload }}
dry_run: "true"
verbosity: "2"
- name: Assert the bump ran
env:
CHANGED: ${{ steps.bump.outputs.changed }}
IMAGE_NAME: ${{ steps.bump.outputs.image_name }}
run: |
set -euo pipefail
if [ "$CHANGED" != "true" ]; then
echo "::error::expected changed=true, got '$CHANGED'"
exit 1
fi
if [ "$IMAGE_NAME" != "ghcr.io/gulfofmaine/odp-releaser-e2e-commit" ]; then
echo "::error::unexpected image_name '$IMAGE_NAME'"
exit 1
fi
command -v odp-releaser
e2e-assert:
name: E2E assert notify and bump outputs
needs: [e2e-payload, e2e-notify, e2e-bump-commit, e2e-bump-pr]
runs-on: ubuntu-latest
permissions: {}
env:
TAG: ${{ needs.e2e-payload.outputs.tag }}
NOTIFY_RESULTS: ${{ needs.e2e-notify.outputs.results }}
NOTIFY_TARGET_COUNT: ${{ needs.e2e-notify.outputs.target_count }}
COMMIT_CHANGED: ${{ needs.e2e-bump-commit.outputs.changed }}
COMMIT_MODE: ${{ needs.e2e-bump-commit.outputs.update_mode }}
COMMIT_MESSAGE: ${{ needs.e2e-bump-commit.outputs.commit_message }}
COMMIT_IMAGE_NAME: ${{ needs.e2e-bump-commit.outputs.image_name }}
COMMIT_DIGEST: ${{ needs.e2e-bump-commit.outputs.digest }}
PR_CHANGED: ${{ needs.e2e-bump-pr.outputs.changed }}
PR_MODE: ${{ needs.e2e-bump-pr.outputs.update_mode }}
PR_TITLE: ${{ needs.e2e-bump-pr.outputs.pr_title }}
PR_BRANCH: ${{ needs.e2e-bump-pr.outputs.branch_name }}
PR_IMAGE_NAME: ${{ needs.e2e-bump-pr.outputs.image_name }}
PR_DIGEST: ${{ needs.e2e-bump-pr.outputs.digest }}
PR_REVIEWERS: ${{ needs.e2e-bump-pr.outputs.reviewers }}
PR_TEAM_REVIEWERS: ${{ needs.e2e-bump-pr.outputs.team_reviewers }}
COMMIT_REVIEWERS: ${{ needs.e2e-bump-commit.outputs.reviewers }}
PR_COMMENT_ENABLED: ${{ needs.e2e-bump-pr.outputs.comment_enabled }}
PR_COMMENT_PR_NUMBER: ${{ needs.e2e-bump-pr.outputs.comment_pr_number }}
PR_COMMENT_STAGED:
${{ needs.e2e-bump-pr.outputs.comment_staged_template }}
PR_COMMENT_DEPLOYED:
${{ needs.e2e-bump-pr.outputs.comment_deployed_template }}
E2E_DIGEST: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
steps:
- name: Assert notify and bump-images outputs
run: |
fail=0
expect() { # expect <label> <actual> <expected>
if [ "$2" != "$3" ]; then
echo "::error::$1: expected '$3', got '$2'"
fail=1
fi
}
expect_contains() { # expect_contains <label> <haystack> <needle>
case "$2" in
*"$3"*) ;;
*) echo "::error::$1 does not contain '$3'"; fail=1 ;;
esac
}
expect "notify target_count" "$NOTIFY_TARGET_COUNT" "2"
expect "notify results length" \
"$(jq 'length' <<<"$NOTIFY_RESULTS")" "2"
expect "notify results all ok" \
"$(jq 'all(.ok)' <<<"$NOTIFY_RESULTS")" "true"
expect "notify results targets" \
"$(jq -r '[.[] | "\(.owner)/\(.repo):\(.event_type)"] | join(",")' \
<<<"$NOTIFY_RESULTS")" \
"gulfofmaine/odp-releaser-e2e-target:image-published,odp-e2e-org/odp-releaser-e2e-target:image-published-e2e"
expect "notify results all dry run" \
"$(jq 'all(.detail == "dry run (not sent)")' <<<"$NOTIFY_RESULTS")" \
"true"
expect "commit-mode changed" "$COMMIT_CHANGED" "true"
expect "commit-mode update_mode" "$COMMIT_MODE" "commit"
expect "commit-mode image_name" "$COMMIT_IMAGE_NAME" \
"ghcr.io/gulfofmaine/odp-releaser-e2e-commit"
expect "commit-mode digest" "$COMMIT_DIGEST" "$E2E_DIGEST"
expect_contains "commit-mode commit_message" "$COMMIT_MESSAGE" \
"Update image ghcr.io/gulfofmaine/odp-releaser-e2e-commit to $TAG"
expect_contains "commit-mode commit_message" "$COMMIT_MESSAGE" \
"commit.kustomization.yaml"
expect "pr-mode changed" "$PR_CHANGED" "true"
expect "pr-mode update_mode" "$PR_MODE" "pull_request"
expect "pr-mode image_name" "$PR_IMAGE_NAME" \
"ghcr.io/gulfofmaine/odp-releaser-e2e-pr"
expect "pr-mode digest" "$PR_DIGEST" "$E2E_DIGEST"
expect "pr-mode pr_title" "$PR_TITLE" \
"Update image ghcr.io/gulfofmaine/odp-releaser-e2e-pr to $TAG"
expect "pr-mode branch_name" "$PR_BRANCH" \
"odp-releaser/bump-ghcr.io-gulfofmaine-odp-releaser-e2e-pr"
expect "pr-mode reviewers" "$PR_REVIEWERS" "odp-releaser-e2e-reviewer"
expect "pr-mode team_reviewers" "$PR_TEAM_REVIEWERS" ""
expect "commit-mode reviewers" "$COMMIT_REVIEWERS" ""
# Commenting is on by default and resolves to the built-in templates,
# but a workflow_dispatch payload carries no source pull request, so
# the empty number is what makes bump-images.yml skip the step.
expect "pr-mode comment_enabled" "$PR_COMMENT_ENABLED" "true"
expect "pr-mode comment_pr_number" "$PR_COMMENT_PR_NUMBER" ""
expect_contains "pr-mode comment_staged_template" \
"$PR_COMMENT_STAGED" "staged for"
expect_contains "pr-mode comment_deployed_template" \
"$PR_COMMENT_DEPLOYED" "deployed to"
exit "$fail"
# The only job anywhere in this repo that touches a real registry. The
# e2e-bump-* jobs above run --dry-run, which skips the sync step outright, so
# without this the sync shell -- the destination loop, the skip-if-identical
# check and the digest verification -- would ship untested.
#
# The script under test is *extracted* from bump_images/action.yml rather
# than restated here, so this cannot quietly drift from what actually runs.
# registry:2 speaks plain HTTP, so skopeo is told to treat it as insecure via
# registries.conf -- confined to this job, rather than adding a
# --tls-verify escape hatch to the action's own inputs for the sake of a test.
e2e-sync:
name: E2E image sync (local registry)
runs-on: ubuntu-latest
permissions:
contents: read
services:
registry:
# registry:2, pinned to its multi-arch index digest
image: registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
ports:
- 5000:5000
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Trust the plain-HTTP test registry
run: |
# Replaces the file rather than appending to it. The ubuntu runner
# ships a v1-format registries.conf (a `[registries.search]` table),
# and containers/image refuses outright to load a file that mixes
# the v1 and v2 formats -- appending a `[[registry]]` block gives
# "mixing sysregistry v1/v2 is not supported" and every skopeo call
# fails. Nothing here needs the v1 search list: every reference in
# this job is fully qualified, and search registries only ever apply
# to unqualified short names.
sudo tee /etc/containers/registries.conf >/dev/null <<'CONF'
[[registry]]
location = "127.0.0.1:5000"
insecure = true
CONF
- name: Extract the sync script from the composite action
run: |
uv run --no-project --with pyyaml python - <<'PY'
import pathlib
import yaml
action = yaml.safe_load(
pathlib.Path(".github/actions/bump_images/action.yml").read_text()
)
step = next(s for s in action["runs"]["steps"] if s.get("id") == "sync")
pathlib.Path("sync.sh").write_text(step["run"])
PY
test -s sync.sh
- name: Seed a multi-arch source image into the test registry
run: |
# mirror.gcr.io rather than Docker Hub directly: no auth and no anonymous
# pull limits, while still serving a real multi-arch manifest list.
skopeo copy --all --preserve-digests \
docker://mirror.gcr.io/library/alpine:3.20 \
docker://127.0.0.1:5000/odp-releaser-e2e-sync-src:seed
- name: Sync to the test registry and assert
run: |
set -euo pipefail
SRC_REPO=127.0.0.1:5000/odp-releaser-e2e-sync-src
DST_A=127.0.0.1:5000/odp-releaser-e2e-sync-dst-a:v1.2.3
DST_B=127.0.0.1:5000/odp-releaser-e2e-sync-dst-b:v1.2.3
digest_of() {
skopeo inspect --raw "docker://$1" | sha256sum | cut -d' ' -f1
}
platforms_of() {
skopeo inspect --raw "docker://$1" | jq '.manifests | length'
}
SRC_DIGEST="sha256:$(digest_of "$SRC_REPO:seed")"
SRC_PLATFORMS="$(platforms_of "$SRC_REPO:seed")"
echo "source $SRC_DIGEST with $SRC_PLATFORMS manifests"
export SOURCE_REF="$SRC_REPO@$SRC_DIGEST"
# Two destinations, so the loop's fan-out path is exercised and not
# just its single-item case. Built with printf rather than a literal
# two-line string: the newline has to survive YAML block-scalar
# dedenting, and a stray leading space would make the second
# destination an invalid reference.
export DESTINATIONS="$(printf '%s\n%s' "$DST_A" "$DST_B")"
export EXPECTED_DIGEST="$SRC_DIGEST"
export GITHUB_OUTPUT="$RUNNER_TEMP/sync_output"
# GITHUB_OUTPUT is append-only, and a real step gets a fresh one per
# invocation. This job runs the script several times in a single
# step, so truncate between runs or the outputs accumulate and no
# longer look like what a real run produces.
run_sync() {
: > "$GITHUB_OUTPUT"
bash sync.sh 2>&1 | tee "$1"
}
echo "::group::first sync"
run_sync first.log
echo "::endgroup::"
FIRST_OUTPUT="$(cat "$GITHUB_OUTPUT")"
echo "::group::second sync (must skip, not re-push)"
run_sync second.log
echo "::endgroup::"
SECOND_OUTPUT="$(cat "$GITHUB_OUTPUT")"
fail=0
expect() {
if [ "$2" = "$3" ]; then
echo "ok: $1"
else
echo "::error::$1: expected '$3', got '$2'"
fail=1
fi
}
# --preserve-digests held and the index was not rewritten in transit.
expect "destination A digest" "sha256:$(digest_of "$DST_A")" \
"$SRC_DIGEST"
expect "destination B digest" "sha256:$(digest_of "$DST_B")" \
"$SRC_DIGEST"
DST="$DST_A"
# The --all regression guard. Without --all skopeo copies only the
# runner's own architecture and --preserve-digests does not object, it
# just preserves that single image's digest instead of the index's --
# so this is the assertion that would catch losing the flag.
expect "destination manifest count" "$(platforms_of "$DST")" \
"$SRC_PLATFORMS"
if [ "$SRC_PLATFORMS" -lt 2 ]; then
echo "::error::source is not multi-arch, so the --all guard above is vacuous"
fail=1
fi
# Both runs report synced=true: one that copied, and one where every
# destination was already up to date.
expect "synced output after copying" "$FIRST_OUTPUT" "synced=true"
expect "synced output after skipping" "$SECOND_OUTPUT" "synced=true"
# Both destinations must be reported, not just the first: this is
# what catches the loop silently processing one item.
for dst in "$DST_A" "$DST_B"; do
if ! grep -q "$dst" first.log; then
echo "::error::first run never mentioned $dst"
fail=1
fi
done
# The second run must take the skip path rather than re-pushing: a
# registry with tag immutability would reject the re-push outright.
if ! grep -q "is already $SRC_DIGEST; skipping" second.log; then
echo "::error::second run did not report skipping an identical digest"
fail=1
fi
if grep -q "^Copying " second.log; then
echo "::error::second run copied again instead of skipping"
fail=1
fi
# A destination holding a DIFFERENT digest must be re-copied, not
# skipped. Only the digests-match case is a no-op; this is the path
# that moves a mirror tag onto a new release.
echo "::group::occupy destination B with a different image"
skopeo copy --all --preserve-digests \
docker://mirror.gcr.io/library/busybox:stable \
"docker://$DST_B"
echo "::endgroup::"
OTHER_DIGEST="sha256:$(digest_of "$DST_B")"
if [ "$OTHER_DIGEST" = "$SRC_DIGEST" ]; then
echo "::error::the two test images share a digest, so the recopy check is vacuous"
fail=1
fi
echo "::group::third sync (must re-copy B, still skip A)"
run_sync third.log
echo "::endgroup::"
expect "destination B re-copied" "sha256:$(digest_of "$DST_B")" \
"$SRC_DIGEST"
if ! grep -q "^Copying .* to $DST_B$" third.log; then
echo "::error::third run did not re-copy the mismatched destination"
fail=1
fi
if ! grep -q "$DST_A is already $SRC_DIGEST; skipping" third.log; then
echo "::error::third run should still have skipped the matching destination"
fail=1
fi
# The post-copy verification must fail the step when the destination
# does not end up at the expected digest. Exercised by lying about
# the expected digest, which is the only way to reach it without a
# registry that rewrites manifests.
echo "::group::mismatch must fail the step"
if EXPECTED_DIGEST="sha256:$(printf '0%.0s' $(seq 64))" \
DESTINATIONS=127.0.0.1:5000/odp-releaser-e2e-sync-dst-c:v1.2.3 \
bash sync.sh >mismatch.log 2>&1; then
echo "::error::a destination digest mismatch did not fail the step"
fail=1
else
echo "ok: digest mismatch failed the step"
fi
echo "::endgroup::"
if ! grep -q "but the manifests were bumped to" mismatch.log; then
echo "::error::mismatch failure did not explain itself"
fail=1
fi
exit "$fail"
pass:
name: All checks passed
if: always()
needs:
[
lint,
checks,
e2e-payload,
e2e-notify,
e2e-bump-commit,
e2e-bump-pr,
e2e-action-self-install,
e2e-assert,
e2e-sync,
]
runs-on: ubuntu-slim
timeout-minutes: 2
steps:
- name: Decide whether the needed jobs succeeded or failed
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
with:
jobs: ${{ toJSON(needs) }}