Skip to content

Composite Actions

Making up the reusable workflows (notify, bump-images, and report-merged), odp-releaser ships four composite GitHub Actions for deploy repos that need more control than bump-images.yml offers — most commonly to run extra steps after the bump (e.g. syncing the freshly published image to another registry that isn't natively reported) before anything is committed, report deployments and pull request comments back to source repos from a custom workflow, or triggering additional deployment steps.

The actions live in this repo and are referenced with the standard owner/repo/path@ref syntax:

uses: gulfofmaine/odp-releaser/.github/actions/install@<sha-or-tag>
uses: gulfofmaine/odp-releaser/.github/actions/bump_images@<sha-or-tag>
uses: gulfofmaine/odp-releaser/.github/actions/report_deployment@<sha-or-tag>
uses: gulfofmaine/odp-releaser/.github/actions/comment_on_pr@<sha-or-tag>

bump_images, report_deployment and comment_on_pr each install the odp-releaser CLI, via the sibling install action at their own ref (GitHub's self-repository syntax, uses: $/.github/actions/install).

install

Installs the odp-releaser CLI with uv. The CLI is installed from the action's own repository files, so the CLI version always matches the action ref — pinning the uses: reference is enough to pin the CLI too.

Optional — the other three call it themselves. Reach for it when a job runs the CLI in its own run: steps, or to control install_uv or the cache key. It is a no-op when the CLI is already on the PATH, so first install wins: pin it and its siblings to the same ref.

- name: Install ODP Releaser
  uses: gulfofmaine/odp-releaser/.github/actions/install@<sha-or-tag>
  # with:
  #   install_uv: "false" # if the job already provides uv on the PATH

Install ODP Releaser

- uses: gulfofmaine/odp-releaser/.github/actions/install@<sha-or-tag>

Install uv (optional) and the odp-releaser CLI from this action's repository.

Inputs: ¤

Name Description Default
install_uv ¤

Whether to install uv with astral-sh/setup-uv. Set to "false" when the job already provides uv on the PATH.

true
cache_suffix ¤

Suffix for setup-uv's cache key, used to keep the uv cache keyed to the odp-releaser version being installed. Defaults to the ref this action was referenced at. Only used when install_uv is "true".

odp-releaser-${{ github.action_ref }}
Source of gulfofmaine/odp-releaser/.github/actions/install@<sha-or-tag>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# Composite action: install the odp-releaser CLI.
#
# Installs the CLI from this action's own repository files, so the CLI version
# always matches the ref the action was referenced at — pinning the action
# reference is enough to pin the CLI too, with no separate version input to
# keep in sync.
#
# Optional: the other actions here run it themselves. It is a no-op when the
# CLI is already on the PATH, so first install wins — pin it and its siblings
# to the same ref.
#
# Minimal caller example:
#
#   - name: Install ODP Releaser
#     uses: gulfofmaine/odp-releaser/.github/actions/install@<sha-or-tag>
#     # with:
#     #   install_uv: "false" # if the job already provides uv on the PATH

name: Install ODP Releaser
description:
  Install uv (optional) and the odp-releaser CLI from this action's repository.

inputs:
  install_uv:
    description: >-
      Whether to install uv with astral-sh/setup-uv. Set to "false" when the job
      already provides uv on the PATH.
    required: false
    default: "true"
  cache_suffix:
    description: >-
      Suffix for setup-uv's cache key, used to keep the uv cache keyed to the
      odp-releaser version being installed. Defaults to the ref this action was
      referenced at. Only used when install_uv is "true".
    required: false
    default: odp-releaser-${{ github.action_ref }}

runs:
  using: composite
  steps:
    - name: Check for an existing install
      id: check
      shell: bash
      run: |
        if command -v odp-releaser >/dev/null; then
          echo "installed=true" >> "$GITHUB_OUTPUT"
        else
          echo "installed=false" >> "$GITHUB_OUTPUT"
        fi

    - name: Set up uv
      if: inputs.install_uv == 'true' && steps.check.outputs.installed != 'true'
      uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
      with:
        enable-cache: true
        cache-dependency-glob: ""
        cache-suffix: ${{ inputs.cache_suffix }}
        prune-cache: false

    - name: Install ODP Releaser
      # GITHUB_ACTION_PATH is this action's directory; three levels up is the
      # repo root, which is installable without git metadata because the
      # project uses a static version.
      if: steps.check.outputs.installed != 'true'
      shell: bash
      run: uv tool install "$GITHUB_ACTION_PATH/../../.."

bump_images

Runs odp-releaser bump-images against the repository_dispatch client_payload and the checked-out deploy repo's image manifest config, then — depending on the image's update_mode — commits the change directly or opens a pull request, exactly like bump-images.yml.

Prerequisites:

  • The deploy repo is checked out, with credentials that can push (unless stage_only is "true").
  • Only when the image manifest asks for a sync (deployed_as with sync: true): skopeo on the PATH (preinstalled on GitHub-hosted ubuntu runners), and the destination registry already logged in to by an earlier step (docker/login-action, or configure-aws-credentials + amazon-ecr-login). skopeo reads those credentials from $HOME/.docker/config.json via the containers credential search order, so no separate skopeo login step is needed. A caller that configures a Docker credential helper instead is the exception, the credentials are then not in that file, and skopeo won't resolve them. See Syncing images for what the sync does and when to ask for one.

stage_only: bump without committing

Set stage_only: "true" to write the manifest changes and git add them without making a commit or opening a pull request (the image's update_mode is ignored). Your workflow then owns the follow-up: add whatever steps you need — the action's outputs carry the image name and digest — and commit the staged changes yourself.

on:
  repository_dispatch:
    types: [image-published]

jobs:
  bump:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@<sha> # v7

      - name: Bump images
        id: bump
        uses: gulfofmaine/odp-releaser/.github/actions/bump_images@<sha-or-tag>
        with:
          stage_only: "true"

      - name: Sync image to the deploy registry
        if: steps.bump.outputs.changed == 'true'
        env:
          IMAGE_NAME: ${{ steps.bump.outputs.image_name }}
          DIGEST: ${{ steps.bump.outputs.digest }}
          NEW_TAG: ${{ steps.bump.outputs.new_tag }}
        run: |
          # The destination needs the tag the bump just wrote. Without it the
          # copy lands as `:latest` while the manifest points at `newTag:
          # <tag>`, so the deploy reads a tag nothing ever pushed.
          crane copy "$IMAGE_NAME@$DIGEST" \
            "registry.example.com/${IMAGE_NAME#*/}:$NEW_TAG"

      - name: Commit bump
        if: steps.bump.outputs.changed == 'true'
        env:
          COMMIT_MESSAGE: ${{ steps.bump.outputs.commit_message }}
        run: |
          git config user.name "odp-releaser[bot]"
          git config user.email "odp-releaser[bot]@users.noreply.github.com"
          git commit -m "$COMMIT_MESSAGE"
          git push
bump_images.yml reference

Bump images

- uses: gulfofmaine/odp-releaser/.github/actions/bump_images@<sha-or-tag>

Bump image references in deployment manifests, then commit, open a PR, or just stage the changes.

Inputs: ¤

Name Description Default
client_payload ¤

repository_dispatch client_payload JSON produced by odp-releaser notify. Defaults to the payload of the workflow's triggering event.

${{ toJSON(github.event.client_payload) }}
config_path ¤

Path to the image manifest config file.

.github/image_manifest.yaml
verbosity ¤

CLI verbosity: 0=warning, 1=info (default), 2 or more=debug. Maps to the CLI's -v/-vv/-vvv flags (capped at 3).

1
git_user_name ¤

Git author/committer name for direct commits.

odp-releaser[bot]
git_user_email ¤

Git author/committer email for direct commits.

odp-releaser[bot]@users.noreply.github.com
stage_only ¤

When "true", write the manifest changes and git add them, but make no commit and open no pull request, leaving follow-up steps to the caller. The image's update_mode is ignored.

false
sync ¤

When "false", skip the image sync even for configs whose sync: true asks for one. The sync otherwise runs whenever the image manifest declares a deployed_as with sync: true, before the bump is committed. Requires skopeo (preinstalled on GitHub-hosted ubuntu runners) and credentials for the destination registry already established by an earlier login step.

true
token ¤

Token used to push the bump commit or open the pull request. Pass an app-minted token if the resulting commit/PR should trigger CI. When the image manifest configures team_reviewers, the token also needs organization "Members: read" to request the team reviews — the bump-images.yml reusable workflow mints one automatically.

${{ github.token }}
Testing aids
dry_run ¤

When "true", run the CLI with --dry-run (no manifest files written) and skip the stage, commit, and pull-request steps. Outputs are still produced. Testing aid; used by this repo's own e2e CI.

false
Reporter app credentials
reporter_apps ¤

Optional JSON object mapping source owner -> {app_id, private_key} reporter app credentials. Used (with organization "Members: read" granted to the app) to check allowed_actors team membership against source orgs.

reporter_app_id ¤

Optional App ID of the source org's reporter GitHub App, used to check allowed_actors team membership. Fallback for owners not in reporter_apps.

reporter_app_private_key ¤

Optional private key matching reporter_app_id.

Outputs: ¤

Name Description
image_name ¤

Image name the bump ran for (no tag or digest).

digest ¤

Digest (sha256:...) of the image the bump ran for.

new_tag ¤

Tag the manifests were bumped to. For a release event this is the release ref, not the payload's tag, so prefer this over reading the client payload directly.

changed ¤

Whether any manifests changed ("true"/"false").

update_mode ¤

Update mode resolved from the image manifest config ("commit"/"pull_request").

environment ¤

GitHub environment name resolved from the image manifest config for deployment reporting; empty when unconfigured.

environment_url ¤

Deployment "View deployment" URL resolved (and templated) from the image manifest config; empty when unconfigured.

pull_request_url ¤

URL of the bump pull request; empty unless a pull_request-mode bump opened or updated one.

branch_name ¤

Branch name used for pull_request mode.

commit_message ¤

Generated commit message for the bump.

pr_title ¤

Generated pull request title for the bump.

pr_body ¤

Generated pull request body for the bump (includes the embedded report metadata).

reviewers ¤

Comma-separated GitHub usernames requested as reviewers on the bump pull request; empty when none are configured.

team_reviewers ¤

Comma-separated GitHub team slugs requested as reviewers on the bump pull request; empty when none are configured.

comment_enabled ¤

Whether commenting back on the source pull request is enabled for this image ("true"/"false").

comment_pr_number ¤

Source pull request the comment lands on; empty for events that carry no pull request (release, workflow_dispatch).

comment_staged_template ¤

Resolved comment template for a bump pull request awaiting review, unrendered; pass to comment_on_pr.

comment_deployed_template ¤

Resolved comment template for a landed bump, unrendered.

sync_source_ref ¤

Image reference the sync copies from, pinned by digest; empty when no config asked for a sync.

sync_destinations ¤

Newline-separated destination references the image was (or would be) copied to; empty when no config asked for a sync.

synced ¤

Whether the sync step actually ran to completion ("true"/"false"). False when nothing was configured, the sync was skipped (sync: "false"), or this was a dry run.

Source of gulfofmaine/odp-releaser/.github/actions/bump_images@<sha-or-tag>
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# Composite action: bump image references in the checked-out deploy repo.
#
# Runs `odp-releaser bump-images` against the incoming client_payload and the
# repo's image manifest config, then — depending on the image's update_mode —
# commits the change directly or opens a pull request. Set `stage_only: true`
# to instead write the manifest changes and `git add` them without committing,
# so the calling workflow can layer follow-up steps (e.g. syncing the image to
# another registry) on top of the staged bump before committing itself.
#
# Prerequisites:
#   - The deploy repo is checked out, with credentials that can push unless
#     stage_only is "true".
#   - Only when the image manifest asks for a sync (`deployed_as` with
#     `sync: true`): `skopeo` on the PATH — preinstalled on GitHub-hosted
#     ubuntu runners — and the destination registry already logged in to by
#     an earlier step (docker/login-action, or configure-aws-credentials +
#     amazon-ecr-login). skopeo reads those credentials from
#     $HOME/.docker/config.json via the containers credential search order,
#     so no separate `skopeo login` is needed. A caller that configures a
#     Docker *credential helper* instead is the exception — the credentials
#     are then not in that file, and skopeo won't resolve them.
#
# Minimal caller example:
#
#   - name: Bump images
#     id: bump
#     uses: gulfofmaine/odp-releaser/.github/actions/bump_images@<sha-or-tag>
#     # with:
#     #   stage_only: "true" # write + `git add` only; no commit, no PR

name: Bump images
description:
  Bump image references in deployment manifests, then commit, open a PR, or just
  stage the changes.

inputs:
  client_payload:
    description: >-
      repository_dispatch client_payload JSON produced by `odp-releaser notify`.
      Defaults to the payload of the workflow's triggering event.
    required: false
    default: ${{ toJSON(github.event.client_payload) }}
  config_path:
    description: Path to the image manifest config file.
    required: false
    default: .github/image_manifest.yaml
  verbosity:
    description: >-
      CLI verbosity: 0=warning, 1=info (default), 2 or more=debug. Maps to the
      CLI's -v/-vv/-vvv flags (capped at 3).
    required: false
    default: "1"
  git_user_name:
    description: Git author/committer name for direct commits.
    required: false
    default: odp-releaser[bot]
  git_user_email:
    description: Git author/committer email for direct commits.
    required: false
    default: odp-releaser[bot]@users.noreply.github.com
  stage_only:
    description: >-
      When "true", write the manifest changes and `git add` them, but make no
      commit and open no pull request, leaving follow-up steps to the caller.
      The image's update_mode is ignored.
    required: false
    default: "false"
  sync:
    description: >-
      When "false", skip the image sync even for configs whose `sync: true` asks
      for one. The sync otherwise runs whenever the image manifest declares a
      `deployed_as` with `sync: true`, before the bump is committed. Requires
      `skopeo` (preinstalled on GitHub-hosted ubuntu runners) and credentials
      for the destination registry already established by an earlier login step.
    required: false
    default: "true"
  dry_run: # group: Testing aids
    description: >-
      When "true", run the CLI with --dry-run (no manifest files written) and
      skip the stage, commit, and pull-request steps. Outputs are still
      produced. Testing aid; used by this repo's own e2e CI.
    required: false
    default: "false"
  token:
    description: >-
      Token used to push the bump commit or open the pull request. Pass an
      app-minted token if the resulting commit/PR should trigger CI. When the
      image manifest configures `team_reviewers`, the token also needs
      organization "Members: read" to request the team reviews — the
      bump-images.yml reusable workflow mints one automatically.
    required: false
    default: ${{ github.token }}
  reporter_apps: # group: Reporter app credentials
    description: >-
      Optional JSON object mapping source owner -> {app_id, private_key}
      reporter app credentials. Used (with organization "Members: read" granted
      to the app) to check `allowed_actors` team membership against source orgs.
    required: false
    default: ""
  reporter_app_id: # group: Reporter app credentials
    description: >-
      Optional App ID of the source org's reporter GitHub App, used to check
      `allowed_actors` team membership. Fallback for owners not in
      reporter_apps.
    required: false
    default: ""
  reporter_app_private_key: # group: Reporter app credentials
    description: Optional private key matching reporter_app_id.
    required: false
    default: ""

outputs:
  image_name:
    description: Image name the bump ran for (no tag or digest).
    value: ${{ steps.bump.outputs.image_name }}
  digest:
    description: Digest (sha256:...) of the image the bump ran for.
    value: ${{ steps.bump.outputs.digest }}
  new_tag:
    description:
      Tag the manifests were bumped to. For a release event this is the release
      ref, not the payload's `tag`, so prefer this over reading the client
      payload directly.
    value: ${{ steps.bump.outputs.new_tag }}
  changed:
    description: Whether any manifests changed ("true"/"false").
    value: ${{ steps.bump.outputs.changed }}
  update_mode:
    description:
      Update mode resolved from the image manifest config
      ("commit"/"pull_request").
    value: ${{ steps.bump.outputs.update_mode }}
  environment:
    description:
      GitHub environment name resolved from the image manifest config for
      deployment reporting; empty when unconfigured.
    value: ${{ steps.bump.outputs.environment }}
  environment_url:
    description:
      Deployment "View deployment" URL resolved (and templated) from the image
      manifest config; empty when unconfigured.
    value: ${{ steps.bump.outputs.environment_url }}
  pull_request_url:
    description:
      URL of the bump pull request; empty unless a pull_request-mode bump opened
      or updated one.
    value: ${{ steps.pr.outputs.pull-request-url }}
  branch_name:
    description: Branch name used for pull_request mode.
    value: ${{ steps.bump.outputs.branch_name }}
  commit_message:
    description: Generated commit message for the bump.
    value: ${{ steps.bump.outputs.commit_message }}
  pr_title:
    description: Generated pull request title for the bump.
    value: ${{ steps.bump.outputs.pr_title }}
  pr_body:
    description: >-
      Generated pull request body for the bump (includes the embedded report
      metadata).
    value: ${{ steps.bump.outputs.pr_body }}
  reviewers:
    description:
      Comma-separated GitHub usernames requested as reviewers on the bump pull
      request; empty when none are configured.
    value: ${{ steps.bump.outputs.reviewers }}
  team_reviewers:
    description:
      Comma-separated GitHub team slugs requested as reviewers on the bump pull
      request; empty when none are configured.
    value: ${{ steps.bump.outputs.team_reviewers }}
  comment_enabled:
    description:
      Whether commenting back on the source pull request is enabled for this
      image ("true"/"false").
    value: ${{ steps.bump.outputs.comment_enabled }}
  comment_pr_number:
    description:
      Source pull request the comment lands on; empty for events that carry no
      pull request (release, workflow_dispatch).
    value: ${{ steps.bump.outputs.comment_pr_number }}
  comment_staged_template:
    description:
      Resolved comment template for a bump pull request awaiting review,
      unrendered; pass to comment_on_pr.
    value: ${{ steps.bump.outputs.comment_staged_template }}
  comment_deployed_template:
    description: Resolved comment template for a landed bump, unrendered.
    value: ${{ steps.bump.outputs.comment_deployed_template }}
  sync_source_ref:
    description:
      Image reference the sync copies from, pinned by digest; empty when no
      config asked for a sync.
    value: ${{ steps.bump.outputs.sync_source_ref }}
  sync_destinations:
    description:
      Newline-separated destination references the image was (or would be)
      copied to; empty when no config asked for a sync.
    value: ${{ steps.bump.outputs.sync_destinations }}
  synced:
    description: >-
      Whether the sync step actually ran to completion ("true"/"false"). False
      when nothing was configured, the sync was skipped (sync: "false"), or this
      was a dry run.
    value: ${{ steps.sync.outputs.synced || 'false' }}

runs:
  using: composite
  steps:
    - name: Install ODP Releaser
      uses: $/.github/actions/install
      with:
        cache_suffix: odp-releaser-${{ github.action_ref }}

    - name: Bump images
      id: bump
      shell: bash
      env:
        IMAGE_MANIFEST_CONFIG_PATH: ${{ inputs.config_path }}
        CLIENT_PAYLOAD: ${{ inputs.client_payload }}
        VERBOSITY: ${{ inputs.verbosity }}
        DRY_RUN: ${{ inputs.dry_run }}
        # Only used to check `allowed_actors` team membership against the
        # source orgs; the CLI makes no other API calls here.
        REPORTER_APPS: ${{ inputs.reporter_apps }}
        REPORTER_APP_ID: ${{ inputs.reporter_app_id }}
        REPORTER_APP_PRIVATE_KEY: ${{ inputs.reporter_app_private_key }}
      run: |
        case "$VERBOSITY" in
          0) FLAGS=() ;;
          1) FLAGS=(-v) ;;
          2) FLAGS=(-vv) ;;
          *) FLAGS=(-vvv) ;;
        esac
        BUMP_ARGS=()
        if [ "$DRY_RUN" = "true" ]; then
          BUMP_ARGS+=(--dry-run)
        fi
        odp-releaser "${FLAGS[@]}" bump-images "${BUMP_ARGS[@]}"

    # Deliberately between the bump and every step that publishes it. A
    # merged manifest pointing at an image nobody pushed is an outage, so
    # unlike the deployment report and the source-PR comment (both
    # continue-on-error, both cosmetic) a failed sync has to fail the bump —
    # which only helps if it runs before the commit or pull request lands.
    - name: Sync image to the deploy registries
      id: sync
      if:
        inputs.dry_run != 'true' && inputs.sync != 'false' &&
        steps.bump.outputs.sync_destinations != ''
      shell: bash
      env:
        SOURCE_REF: ${{ steps.bump.outputs.sync_source_ref }}
        DESTINATIONS: ${{ steps.bump.outputs.sync_destinations }}
        EXPECTED_DIGEST: ${{ steps.bump.outputs.digest }}
      run: |
        set -euo pipefail

        if ! command -v skopeo >/dev/null; then
          echo "::error::skopeo not found on the PATH; it is preinstalled on" \
            "GitHub-hosted ubuntu runners, so a self-hosted runner needs it" \
            "installed to sync images to another registry"
          exit 1
        fi

        # A manifest's digest is the digest of its raw bytes, so this is exact
        # for a single image and for a multi-arch index alike. Plain `skopeo
        # inspect` has to resolve an index down to one platform before it can
        # parse it; --raw never does, so it can't disagree with the registry.
        manifest_digest() {
          skopeo inspect --raw "docker://$1" 2>/dev/null \
            | sha256sum | cut -d' ' -f1
        }

        while IFS= read -r destination; do
          [ -n "$destination" ] || continue

          # Skip a digest that is already there instead of re-pushing it.
          # Registries with tag immutability (ECR, and GHCR/Docker Hub with
          # immutability enabled) reject a re-push of the same tag, and a
          # re-dispatch of an already-mirrored digest is a legitimate no-op.
          # A destination that doesn't exist yet exits non-zero here, which
          # simply means "not there, copy it".
          if current="$(manifest_digest "$destination")" &&
            [ "sha256:$current" = "$EXPECTED_DIGEST" ]; then
            echo "$destination is already $EXPECTED_DIGEST; skipping"
            continue
          fi

          echo "Copying $SOURCE_REF to $destination"
          # --all is load-bearing, not belt-and-braces: without it skopeo
          # copies only the runner's own architecture, and --preserve-digests
          # does *not* object — it preserves that single image's digest
          # instead of the index's, landing a digest the manifests were never
          # bumped to. With --all, --preserve-digests fails loudly rather
          # than silently rewriting anything.
          # --retry-times because the default is 0: a single transient 5xx or
          # reset connection part-way through a multi-arch push would
          # otherwise fail the bump, and this step failing is deliberately
          # fatal. Retrying is the cheapest way to keep that strictness from
          # manufacturing outages out of blips.
          skopeo copy --all --preserve-digests --retry-times 3 \
            "docker://$SOURCE_REF" "docker://$destination"

          copied="$(manifest_digest "$destination")"
          if [ "sha256:$copied" != "$EXPECTED_DIGEST" ]; then
            echo "::error::$destination is sha256:$copied after the copy," \
              "but the manifests were bumped to $EXPECTED_DIGEST"
            exit 1
          fi
        done <<< "$DESTINATIONS"

        echo "synced=true" >> "$GITHUB_OUTPUT"

    - name: Stage bump
      if:
        inputs.dry_run != 'true' && inputs.stage_only == 'true' &&
        steps.bump.outputs.changed == 'true'
      shell: bash
      run: git add -A

    - name: Commit bump
      if:
        inputs.dry_run != 'true' && inputs.stage_only != 'true' &&
        steps.bump.outputs.changed == 'true' && steps.bump.outputs.update_mode
        == 'commit'
      shell: bash
      env:
        GIT_USER_NAME: ${{ inputs.git_user_name }}
        GIT_USER_EMAIL: ${{ inputs.git_user_email }}
        COMMIT_MESSAGE: ${{ steps.bump.outputs.commit_message }}
      run: |
        git config user.name "$GIT_USER_NAME"
        git config user.email "$GIT_USER_EMAIL"
        git add -A
        git commit -m "$COMMIT_MESSAGE"
        git push

    - name: Open bump pull request
      id: pr
      if:
        inputs.dry_run != 'true' && inputs.stage_only != 'true' &&
        steps.bump.outputs.changed == 'true' && steps.bump.outputs.update_mode
        == 'pull_request'
      uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 # zizmor: ignore[superfluous-actions]
      with:
        token: ${{ inputs.token }}
        branch: ${{ steps.bump.outputs.branch_name }}
        commit-message: ${{ steps.bump.outputs.commit_message }}
        title: ${{ steps.bump.outputs.pr_title }}
        body: ${{ steps.bump.outputs.pr_body }}
        committer: ${{ inputs.git_user_name }} <${{ inputs.git_user_email }}>
        author: ${{ inputs.git_user_name }} <${{ inputs.git_user_email }}>
        delete-branch: true
        # Empty values are treated as "not provided" upstream, so these are
        # safe to pass unconditionally.
        reviewers: ${{ steps.bump.outputs.reviewers }}
        team-reviewers: ${{ steps.bump.outputs.team_reviewers }}

report_deployment

Runs odp-releaser report-deployment, which creates (or finds) a GitHub deployment on the source repository at the commit that built the image and sets its status — success for a bump committed directly, queued for a bump pull request that still needs review. bump-images.yml runs this action after a successful bump, and report-merged.yml runs it when a bump PR merges; use it directly when composing your own workflow from the bump_images action.

Provide exactly one of:

  • client_payload — right after a bump, the same payload the bump ran with;
  • pr_body — after a bump pull request closed, the body of that PR. The payload, environment, and environment URL that bump_images embedded in the body at bump time are read back out, and the queued deployment from the bump is found (same commit + environment) and updated instead of a duplicate being created. A body without embedded metadata is a friendly no-op, so running on any closed PR is safe.

Prerequisites:

  • Reporter app credentials for the source org — see GitHub Apps. The minted token is scoped to the single source repository with deployments: write only.

A failed report exits non-zero and fails the step; wrap the action in continue-on-error: true (as bump-images.yml does) when reporting should be best-effort rather than a hard failure.

on:
  pull_request:
    types: [closed]

jobs:
  report:
    if: >-
      github.event.pull_request.merged == true &&
      startsWith(github.event.pull_request.head.ref, 'odp-releaser/')
    runs-on: ubuntu-latest
    steps:
      - name: Report merged deployment
        uses: gulfofmaine/odp-releaser/.github/actions/report_deployment@<sha-or-tag>
        with:
          pr_body: ${{ github.event.pull_request.body }}
          environment_url: >-
            ${{ github.server_url }}/${{ github.repository }}/commit/${{
            github.event.pull_request.merge_commit_sha }}
          reporter_app_id: ${{ secrets.REPORTER_APP_ID }}
          reporter_app_private_key: ${{ secrets.REPORTER_APP_PRIVATE_KEY }}

(That example is what report-merged.yml packages up — prefer the reusable workflow unless you need to customize it.)

report_deployment.yml Reference

Report deployment

- uses: gulfofmaine/odp-releaser/.github/actions/report_deployment@<sha-or-tag>

Report a completed image bump back to the source repo as a GitHub deployment and status.

Inputs: ¤

Name Description Default
client_payload ¤

repository_dispatch client_payload JSON produced by odp-releaser notify. Provide either this or pr_body.

pr_body ¤

Body of a merged bump pull request; the payload, environment, and environment URL embedded at bump time are read from it. Provide either this or client_payload. A body without embedded metadata is a no-op.

update_mode ¤

How the bump landed ("commit" reports a success deployment, "pull_request" reports a queued one).

commit
environment ¤

GitHub environment name for the deployment. An environment embedded in pr_body wins; empty falls back to the deploy repo's owner/name slug.

environment_url ¤

"View deployment" link for the deployment status — typically the bump commit or pull request URL. A URL embedded in pr_body wins.

verbosity ¤

CLI verbosity: 0=warning, 1=info (default), 2 or more=debug. Maps to the CLI's -v/-vv/-vvv flags (capped at 3).

1
Reporter app credentials
reporter_apps ¤

JSON object mapping source owner -> {app_id, private_key} reporter app credentials, for deploy repos that report to multiple source orgs.

reporter_app_id ¤

App ID of the reporter GitHub App installed on the source repos.

reporter_app_private_key ¤

Private key matching reporter_app_id.

Source of gulfofmaine/odp-releaser/.github/actions/report_deployment@<sha-or-tag>
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# Composite action: report a completed image bump back to the source repo.
#
# Runs `odp-releaser report-deployment`, which creates (or finds) a GitHub
# deployment on the *source* repository at the commit that built the image
# and sets its status: `success` for a bump committed directly, `queued` for
# a bump pull request that still needs review. Reporting is idempotent — an
# existing deployment for the same commit + environment has its status
# updated, which is how a merge-time run (`pr_body`) flips the bump PR's
# queued deployment to success.
#
# Provide exactly one of `client_payload` (right after a bump) or `pr_body`
# (after a bump pull request merged; the payload embedded at bump time is
# read back out of the body). A `pr_body` without embedded odp-releaser
# metadata is a friendly no-op, so it's safe to run on any closed PR.
#
# Prerequisites:
#   - Reporter app credentials for the source org — see the GitHub Apps docs.
#
# Minimal caller example:
#
#   - name: Report deployment
#     uses: gulfofmaine/odp-releaser/.github/actions/report_deployment@<sha-or-tag>
#     with:
#       update_mode: ${{ steps.bump.outputs.update_mode }}
#       reporter_app_id: ${{ secrets.REPORTER_APP_ID }}
#       reporter_app_private_key: ${{ secrets.REPORTER_APP_PRIVATE_KEY }}

name: Report deployment
description:
  Report a completed image bump back to the source repo as a GitHub deployment
  and status.

inputs:
  client_payload:
    description: >-
      repository_dispatch client_payload JSON produced by `odp-releaser notify`.
      Provide either this or pr_body.
    required: false
    default: ""
  pr_body:
    description: >-
      Body of a merged bump pull request; the payload, environment, and
      environment URL embedded at bump time are read from it. Provide either
      this or client_payload. A body without embedded metadata is a no-op.
    required: false
    default: ""
  update_mode:
    description: >-
      How the bump landed ("commit" reports a success deployment, "pull_request"
      reports a queued one).
    required: false
    default: commit
  environment:
    description: >-
      GitHub environment name for the deployment. An environment embedded in
      pr_body wins; empty falls back to the deploy repo's owner/name slug.
    required: false
    default: ""
  environment_url:
    description: >-
      "View deployment" link for the deployment status — typically the bump
      commit or pull request URL. A URL embedded in pr_body wins.
    required: false
    default: ""
  verbosity:
    description: >-
      CLI verbosity: 0=warning, 1=info (default), 2 or more=debug. Maps to the
      CLI's -v/-vv/-vvv flags (capped at 3).
    required: false
    default: "1"
  reporter_apps: # group: Reporter app credentials
    description: >-
      JSON object mapping source owner -> {app_id, private_key} reporter app
      credentials, for deploy repos that report to multiple source orgs.
    required: false
    default: ""
  reporter_app_id: # group: Reporter app credentials
    description: >-
      App ID of the reporter GitHub App installed on the source repos.
    required: false
    default: ""
  reporter_app_private_key: # group: Reporter app credentials
    description: Private key matching reporter_app_id.
    required: false
    default: ""

runs:
  using: composite
  steps:
    - name: Install ODP Releaser
      uses: $/.github/actions/install
      with:
        cache_suffix: odp-releaser-${{ github.action_ref }}

    - name: Report deployment
      shell: bash
      env:
        CLIENT_PAYLOAD: ${{ inputs.client_payload }}
        PR_BODY: ${{ inputs.pr_body }}
        UPDATE_MODE: ${{ inputs.update_mode }}
        ENVIRONMENT: ${{ inputs.environment }}
        ENVIRONMENT_URL: ${{ inputs.environment_url }}
        VERBOSITY: ${{ inputs.verbosity }}
        REPORTER_APPS: ${{ inputs.reporter_apps }}
        REPORTER_APP_ID: ${{ inputs.reporter_app_id }}
        REPORTER_APP_PRIVATE_KEY: ${{ inputs.reporter_app_private_key }}
      run: |
        case "$VERBOSITY" in
          0) FLAGS=() ;;
          1) FLAGS=(-v) ;;
          2) FLAGS=(-vv) ;;
          *) FLAGS=(-vvv) ;;
        esac
        odp-releaser "${FLAGS[@]}" report-deployment

comment_on_pr

Runs odp-releaser comment, which posts (or updates) a markdown comment on the source repository's pull request saying which image was bumped and where — the readable counterpart to the deployment record. bump-images.yml runs this action after the deployment report, and report-merged.yml runs it when a bump PR merges; use it directly when composing your own workflow from the bump_images action.

Which template is used follows update_mode: pull_request posts the staged comment (the bump is waiting on review, nothing is live), commit posts the deployed one. See Pull request comments for the templates and their placeholders.

Provide exactly one of:

  • client_payload — right after a bump, the same payload the bump ran with, with the templates passed in from the bump_images outputs;
  • pr_body — after a bump pull request closed, the body of that PR. The payload, environment, comment templates and source pull request number that bump_images embedded at bump time are read back out, so no image manifest (and no deploy-repo checkout) is needed. A body without embedded metadata, or one from before comment support, is a friendly no-op.

Reruns update the same comment rather than adding another: it is found by an invisible marker keyed on the deploy repo, the image, and the environment, so another deploy repo's or another image's comment on the same pull request is never touched.

Prerequisites:

  • Reporter app credentials for the source org, whose app has been granted Pull requests: Read and write and whose installations have accepted that permission — see Pull request comments. The minted token is scoped to the single source repository with pull_requests: write only.

Nothing is posted, and the step still succeeds, when comment_enabled is "false", when the chosen template is empty, or when there is no source pull request to comment on (only push payloads carry one). Other failures exit non-zero; wrap the action in continue-on-error: true (as bump-images.yml does) when commenting should be best-effort.

- name: Bump images
  id: bump
  uses: gulfofmaine/odp-releaser/.github/actions/bump_images@<sha-or-tag>

- name: Comment on the source pull request
  if: steps.bump.outputs.comment_pr_number != ''
  continue-on-error: true
  uses: gulfofmaine/odp-releaser/.github/actions/comment_on_pr@<sha-or-tag>
  with:
    update_mode: ${{ steps.bump.outputs.update_mode }}
    environment: ${{ steps.bump.outputs.environment }}
    bump_url: ${{ steps.bump.outputs.pull_request_url }}
    pr_number: ${{ steps.bump.outputs.comment_pr_number }}
    comment_enabled: ${{ steps.bump.outputs.comment_enabled }}
    staged_template: ${{ steps.bump.outputs.comment_staged_template }}
    deployed_template: ${{ steps.bump.outputs.comment_deployed_template }}
    reporter_app_id: ${{ secrets.REPORTER_APP_ID }}
    reporter_app_private_key: ${{ secrets.REPORTER_APP_PRIVATE_KEY }}
comment_on_pr.yml reference

Comment on source pull request

- uses: gulfofmaine/odp-releaser/.github/actions/comment_on_pr@<sha-or-tag>

Comment on the source repository's pull request saying where an image was deployed.

Inputs: ¤

Name Description Default
client_payload ¤

repository_dispatch client_payload JSON produced by odp-releaser notify. Provide either this or pr_body.

pr_body ¤

Body of a merged bump pull request; the payload, environment, comment templates and source pull request number embedded at bump time are read from it. Provide either this or client_payload. A body without embedded metadata is a no-op.

update_mode ¤

How the bump landed ("commit" posts the deployed comment, "pull_request" posts the staged one).

commit
environment ¤

GitHub environment name named in the comment, and part of the comment's identity. An environment embedded in pr_body wins; empty falls back to the deploy repo's owner/name slug.

environment_url ¤

Available to templates as {environment_url}. An URL embedded in pr_body wins; empty falls back to bump_url.

bump_url ¤

Where the bump itself lives — the bump commit or pull request URL — available to templates as {bump_url}.

run_url ¤

Available to templates as {run_url}. Empty (the default) uses this workflow run, which is normally what you want; set it to point a comment at a different run — e.g. the job that actually deployed, when the bump is one step of a longer pipeline.

pr_number ¤

Source pull request to comment on (the comment_pr_number output of bump-images). Empty falls back to the payload's own pull request; no pull request at all is a no-op.

comment_enabled ¤

Whether to comment at all (the comment_enabled output of bump-images). "false" is a no-op.

true
staged_template ¤

Comment body used for a pull_request-mode bump (the comment_staged_template output of bump-images). A template embedded in pr_body wins.

deployed_template ¤

Comment body used once the bump has landed (the comment_deployed_template output of bump-images). A template embedded in pr_body wins.

verbosity ¤

CLI verbosity: 0=warning, 1=info (default), 2 or more=debug. Maps to the CLI's -v/-vv/-vvv flags (capped at 3).

1
Reporter app credentials
reporter_apps ¤

JSON object mapping source owner -> {app_id, private_key} reporter app credentials, for deploy repos that report to multiple source orgs.

reporter_app_id ¤

App ID of the reporter GitHub App installed on the source repos. It must be granted Pull requests: Read and write for commenting.

reporter_app_private_key ¤

Private key matching reporter_app_id.

Source of gulfofmaine/odp-releaser/.github/actions/comment_on_pr@<sha-or-tag>
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# Composite action: comment on the source pull request about a bump.
#
# Runs `odp-releaser comment`, which posts (or updates) a markdown comment on
# the *source* repository's pull request saying which image was bumped and
# where. A `pull_request`-mode bump posts the `staged` comment — nothing is
# live until the bump pull request merges — and a landed bump posts the
# `deployed` one. Reruns update the same comment rather than adding another:
# it's found by an invisible marker keyed on this deploy repo, the image, and
# the environment, so other deploy repos' comments are never touched.
#
# Provide exactly one of `client_payload` (right after a bump) or `pr_body`
# (after a bump pull request merged; the templates, environment and source pull
# request number embedded at bump time are read back out of the body). A
# `pr_body` without embedded odp-releaser metadata is a friendly no-op, so it's
# safe to run on any closed PR.
#
# Prerequisites:
#   - Reporter app credentials for the source org, whose app has been granted
#     `Pull requests: Read and write` — and whose existing installations have
#     accepted that permission. See the GitHub Apps docs.
#
# Minimal caller example:
#
#   - name: Comment on the source pull request
#     uses: gulfofmaine/odp-releaser/.github/actions/comment_on_pr@<sha-or-tag>
#     with:
#       update_mode: ${{ steps.bump.outputs.update_mode }}
#       staged_template: ${{ steps.bump.outputs.comment_staged_template }}
#       deployed_template: ${{ steps.bump.outputs.comment_deployed_template }}
#       reporter_app_id: ${{ secrets.REPORTER_APP_ID }}
#       reporter_app_private_key: ${{ secrets.REPORTER_APP_PRIVATE_KEY }}

name: Comment on source pull request
description:
  Comment on the source repository's pull request saying where an image was
  deployed.

inputs:
  client_payload:
    description: >-
      repository_dispatch client_payload JSON produced by `odp-releaser notify`.
      Provide either this or pr_body.
    required: false
    default: ""
  pr_body:
    description: >-
      Body of a merged bump pull request; the payload, environment, comment
      templates and source pull request number embedded at bump time are read
      from it. Provide either this or client_payload. A body without embedded
      metadata is a no-op.
    required: false
    default: ""
  update_mode:
    description: >-
      How the bump landed ("commit" posts the deployed comment, "pull_request"
      posts the staged one).
    required: false
    default: commit
  environment:
    description: >-
      GitHub environment name named in the comment, and part of the comment's
      identity. An environment embedded in pr_body wins; empty falls back to the
      deploy repo's owner/name slug.
    required: false
    default: ""
  environment_url:
    description: >-
      Available to templates as `{environment_url}`. An URL embedded in pr_body
      wins; empty falls back to bump_url.
    required: false
    default: ""
  bump_url:
    description: >-
      Where the bump itself lives — the bump commit or pull request URL —
      available to templates as `{bump_url}`.
    required: false
    default: ""
  run_url:
    description: >-
      Available to templates as `{run_url}`. Empty (the default) uses this
      workflow run, which is normally what you want; set it to point a comment
      at a different run — e.g. the job that actually deployed, when the bump is
      one step of a longer pipeline.
    required: false
    default: ""
  pr_number:
    description: >-
      Source pull request to comment on (the `comment_pr_number` output of
      `bump-images`). Empty falls back to the payload's own pull request; no
      pull request at all is a no-op.
    required: false
    default: ""
  comment_enabled:
    description: >-
      Whether to comment at all (the `comment_enabled` output of `bump-images`).
      "false" is a no-op.
    required: false
    default: "true"
  staged_template:
    description: >-
      Comment body used for a `pull_request`-mode bump (the
      `comment_staged_template` output of `bump-images`). A template embedded in
      pr_body wins.
    required: false
    default: ""
  deployed_template:
    description: >-
      Comment body used once the bump has landed (the
      `comment_deployed_template` output of `bump-images`). A template embedded
      in pr_body wins.
    required: false
    default: ""
  verbosity:
    description: >-
      CLI verbosity: 0=warning, 1=info (default), 2 or more=debug. Maps to the
      CLI's -v/-vv/-vvv flags (capped at 3).
    required: false
    default: "1"
  reporter_apps: # group: Reporter app credentials
    description: >-
      JSON object mapping source owner -> {app_id, private_key} reporter app
      credentials, for deploy repos that report to multiple source orgs.
    required: false
    default: ""
  reporter_app_id: # group: Reporter app credentials
    description: >-
      App ID of the reporter GitHub App installed on the source repos. It must
      be granted `Pull requests: Read and write` for commenting.
    required: false
    default: ""
  reporter_app_private_key: # group: Reporter app credentials
    description: Private key matching reporter_app_id.
    required: false
    default: ""

runs:
  using: composite
  steps:
    - name: Install ODP Releaser
      uses: $/.github/actions/install
      with:
        cache_suffix: odp-releaser-${{ github.action_ref }}

    - name: Comment on source pull request
      shell: bash
      env:
        CLIENT_PAYLOAD: ${{ inputs.client_payload }}
        PR_BODY: ${{ inputs.pr_body }}
        UPDATE_MODE: ${{ inputs.update_mode }}
        ENVIRONMENT: ${{ inputs.environment }}
        ENVIRONMENT_URL: ${{ inputs.environment_url }}
        BUMP_URL: ${{ inputs.bump_url }}
        RUN_URL: ${{ inputs.run_url }}
        COMMENT_PR_NUMBER: ${{ inputs.pr_number }}
        COMMENT_ENABLED: ${{ inputs.comment_enabled }}
        COMMENT_STAGED_TEMPLATE: ${{ inputs.staged_template }}
        COMMENT_DEPLOYED_TEMPLATE: ${{ inputs.deployed_template }}
        VERBOSITY: ${{ inputs.verbosity }}
        REPORTER_APPS: ${{ inputs.reporter_apps }}
        REPORTER_APP_ID: ${{ inputs.reporter_app_id }}
        REPORTER_APP_PRIVATE_KEY: ${{ inputs.reporter_app_private_key }}
      run: |
        case "$VERBOSITY" in
          0) FLAGS=() ;;
          1) FLAGS=(-v) ;;
          2) FLAGS=(-vv) ;;
          *) FLAGS=(-vvv) ;;
        esac
        odp-releaser "${FLAGS[@]}" comment