Skip to content

Bump images workflow

Runs in the deployment repo, triggered by the repository_dispatch event that notify sends. It matches the incoming image against .github/image_manifest.yaml and either commits the updated manifests directly or opens a pull request, depending on that image's update_mode.

An image with no entry at all in images is treated as a configuration error: bump-images exits non-zero and lists the images that are configured. An image that has an entry but an empty list of configs is a deliberate no-op and succeeds without changes.

Caller example

on:
  repository_dispatch:
    types: [image-published]

concurrency:
  group: bump-images-${{ github.event.client_payload.image_name }}
  cancel-in-progress: false

jobs:
  bump:
    permissions:
      contents: write
      pull-requests: write
      id-token: write # required even if this deploy repo never syncs
    uses: gulfofmaine/odp-releaser/.github/workflows/bump-images.yml@<sha>
    with:
      # config_path: .github/image_manifest.yaml            # optional
      # git_user_name: odp-releaser[bot]                    # optional
      # git_user_email: odp-releaser[bot]@users.noreply.github.com
      # verbosity: 1                                       # optional, default
    secrets:
      ci_app_id: ${{ secrets.CI_APP_ID }} # optional
      ci_app_private_key: ${{ secrets.CI_APP_PRIVATE_KEY }} # optional
      reporter_app_id: ${{ secrets.REPORTER_APP_ID }} # optional
      reporter_app_private_key: ${{ secrets.REPORTER_APP_PRIVATE_KEY }} # optional
      # reporter_apps: ${{ secrets.REPORTER_APPS }}    # optional multi-org

Set the concurrency group at the caller level too (as above) — a burst of dispatches for the same image shouldn't run two bump jobs in parallel and race each other's commits. The reusable workflow itself also sets a job-level concurrency group keyed on client_payload.image_name, but the caller-side group protects against overlapping workflow runs triggered in quick succession.

id-token: write is required of every caller — see Syncing to ECR via OIDC for why, and for the alternative if that trade isn't acceptable.

bump_images.yml Reference

Bump images

uses: gulfofmaine/odp-releaser/.github/workflows/bump-images.yml@<sha-or-tag>
permissions:
  contents: write
  pull-requests: write
  id-token: write

Inputs: ¤

Name Description Default
config_path ¤

Path to the image manifest config file.

.github/image_manifest.yaml
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
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
Testing aids
client_payload ¤

Explicit client_payload JSON string. Empty (the default) uses the triggering repository_dispatch event's client_payload. Testing aid for calling this workflow outside a repository_dispatch run.

dry_run ¤

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

false
Image sync
sync ¤

Set false to skip the image sync even for configs whose sync: true asks for one. Only relevant when the image manifest declares a deployed_as with sync: true.

true
sync_aws_role_arn ¤

IAM role to assume via OIDC before syncing, for an ECR destination. When set, this workflow runs configure-aws-credentials and amazon-ecr-login before the bump. Not a secret: a role ARN is not sensitive, and OIDC needs no stored credential. The role's trust policy keys on this calling repo (e.g. repo:my-org/my-deploy-repo:*) -- the OIDC subject describes the caller even though the login runs here, so each deploy repo gets its own tightly scoped role and nothing has to trust every caller of this workflow.

sync_aws_region ¤

AWS region for sync_aws_role_arn.

Secrets: ¤

Name Description
ci_app_id ¤

Optional App ID of this repo's GitHub App. When set, the commit/PR is authored with an app token so it triggers this repo's own CI. Also needed for team_reviewers in the image manifest config: when that key is present, the app token is minted with organization "Members: read" added, so the app must be granted that permission.

ci_app_private_key ¤

Optional private key matching ci_app_id.

reporter_apps ¤

Optional JSON object mapping source owner -> {app_id, private_key} reporter app credentials, for deploy repos that receive dispatches from multiple source orgs. Also used to check allowed_actors team membership against source orgs (the app needs organization "Members: read" there).

reporter_app_id ¤

Optional App ID of the source org's reporter GitHub App. When set (with reporter_app_private_key), a successful bump is reported back to the source repo as a GitHub deployment + status, and allowed_actors team membership is checked with it (the app needs organization "Members: read" for that). Commenting on the source pull request additionally needs "Pull requests: Read and write"; without it the comment step fails harmlessly and the deployment report still lands.

reporter_app_private_key ¤

Optional private key matching reporter_app_id.

Image sync
sync_registry ¤

Registry host to docker login to before syncing, for a destination that uses username/password auth (GHCR, Docker Hub). Leave unset when using sync_aws_role_arn, or when no config asks for a sync.

sync_username ¤

Username for sync_registry.

sync_password ¤

Password or token for sync_registry.

sync_source_registry ¤

Registry host to docker login to for the source side of a sync. Only needed when the image is pulled from somewhere that requires credentials the runner doesn't already have -- a private registry on a different host than the destination, or Docker Hub, where an anonymous pull shares the runner's IP rate limit. Since a failed copy fails the bump on purpose, an unauthenticated source is a source of intermittent release failures.

sync_source_username ¤

Username for sync_source_registry.

sync_source_password ¤

Password or token for sync_source_registry.

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.

changed ¤

Whether any manifest content changed ("true"/"false").

update_mode ¤

Resolved update mode ("commit" or "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 from the image manifest config; empty when unconfigured.

branch_name ¤

Branch name a pull_request-mode bump uses (odp-releaser/bump-).

commit_message ¤

Full commit message for the bump.

pr_title ¤

Title for the bump pull request.

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.

comment_deployed_template ¤

Resolved comment template for a landed bump, unrendered.

sync_destinations ¤

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

synced ¤

Whether the image sync actually ran ("true"/"false").

Source of gulfofmaine/odp-releaser/.github/workflows/bump-images.yml@<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
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
# Reusable workflow: bump image references in a deploy repo.
#
# This workflow is meant to be called by a deploy repo in response to a
# repository_dispatch event sent by `odp-releaser notify`. It runs the
# `bump_images` composite action from this repo (resolved at the same commit
# as this workflow file), which reads the incoming client_payload and
# this repo's image manifest, then either commits the bump directly or opens
# a pull request with the updated image references.
#
# When the deploy org's own dispatch-app credentials are supplied (via the
# ci_app_id / ci_app_private_key secrets), the commit/PR is authored with an
# app-minted token so the resulting change triggers this repo's own CI.
# Pushes/PRs authored with the default GITHUB_TOKEN do not trigger further
# workflow runs by GitHub Actions design.
#
# When the source org's reporter-app credentials are supplied (via the
# reporter_app_id / reporter_app_private_key secrets), a successful bump is
# also reported back to the source repo as a GitHub deployment + status, so
# the source PR timeline and Environments sidebar show where the image went.
# If that app has additionally been granted `Pull requests: Read and write`,
# the bump also comments on the source pull request — `staged` while a bump PR
# awaits review, `deployed` once it lands. Both reports are best effort and
# never fail the bump.
#
# When an image manifest declares a `deployed_as` with `sync: true`, the image
# is also copied to that registry before the bump is committed. Supply
# credentials for the destination either with the sync_aws_role_arn /
# sync_aws_region inputs (ECR via OIDC) or the sync_registry / sync_username /
# sync_password secrets (GHCR, Docker Hub). A failed copy fails the bump, on
# purpose: a merged manifest pointing at an image nobody pushed is an outage.
#
# UPGRADING: this workflow now requests `id-token: write`, which is never
# granted by default, and a called workflow's permissions can only be
# maintained or reduced relative to its caller -- never elevated. So every
# calling job must now grant it, even one that never syncs:
#
#   permissions:
#     contents: write
#     pull-requests: write
#     id-token: write   # <- add this
#
# Minimal caller example:
#
#   on:
#     repository_dispatch:
#       types: [image-published]
#
#   jobs:
#     bump:
#       permissions:
#         contents: write
#         pull-requests: write
#         id-token: write
#       uses: gulfofmaine/odp-releaser/.github/workflows/bump-images.yml@<ref>
#       with:
#         # config_path: .github/image_manifest.yaml            # optional
#         # git_user_name: odp-releaser[bot]                    # optional
#         # git_user_email: odp-releaser[bot]@users.noreply.github.com
#         # verbosity: 1                                       # optional, default
#       secrets:
#         ci_app_id: ${{ secrets.CI_APP_ID }}                   # optional
#         ci_app_private_key: ${{ secrets.CI_APP_PRIVATE_KEY }} # optional
#         reporter_apps: ${{ secrets.REPORTER_APPS }}           # optional
#         reporter_app_id: ${{ secrets.REPORTER_APP_ID }}       # optional
#         reporter_app_private_key: ${{ secrets.REPORTER_APP_PRIVATE_KEY }} # optional

name: Bump images

on:
  workflow_call:
    inputs:
      config_path:
        description: Path to the image manifest config file.
        required: false
        type: string
        default: .github/image_manifest.yaml
      git_user_name:
        description: Git author/committer name for direct commits.
        required: false
        type: string
        default: odp-releaser[bot]
      git_user_email:
        description: Git author/committer email for direct commits.
        required: false
        type: string
        default: odp-releaser[bot]@users.noreply.github.com
      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
        type: number
        default: 1
      client_payload: # group: Testing aids
        description: >-
          Explicit client_payload JSON string. Empty (the default) uses the
          triggering repository_dispatch event's client_payload. Testing aid for
          calling this workflow outside a repository_dispatch run.
        required: false
        type: string
        default: ""
      dry_run: # group: Testing aids
        description: >-
          Run the CLI with --dry-run (no manifest files written) and skip the
          commit and pull-request steps. Outputs are still produced. Testing
          aid; used by this repo's own e2e CI.
        required: false
        type: boolean
        default: false
      sync: # group: Image sync
        description: >-
          Set false to skip the image sync even for configs whose `sync: true`
          asks for one. Only relevant when the image manifest declares a
          `deployed_as` with `sync: true`.
        required: false
        type: boolean
        default: true
      sync_aws_role_arn: # group: Image sync
        description: >-
          IAM role to assume via OIDC before syncing, for an ECR destination.
          When set, this workflow runs configure-aws-credentials and
          amazon-ecr-login before the bump. Not a secret: a role ARN is not
          sensitive, and OIDC needs no stored credential. The role's trust
          policy keys on this *calling* repo (e.g.
          `repo:my-org/my-deploy-repo:*`) -- the OIDC subject describes the
          caller even though the login runs here, so each deploy repo gets its
          own tightly scoped role and nothing has to trust every caller of this
          workflow.
        required: false
        type: string
        default: ""
      sync_aws_region: # group: Image sync
        description: AWS region for sync_aws_role_arn.
        required: false
        type: string
        default: ""
    outputs:
      image_name:
        description: Image name the bump ran for (no tag or digest).
        value: ${{ jobs.bump.outputs.image_name }}
      digest:
        description: Digest (sha256:...) of the image the bump ran for.
        value: ${{ jobs.bump.outputs.digest }}
      changed:
        description: Whether any manifest content changed ("true"/"false").
        value: ${{ jobs.bump.outputs.changed }}
      update_mode:
        description: Resolved update mode ("commit" or "pull_request").
        value: ${{ jobs.bump.outputs.update_mode }}
      environment:
        description: >-
          GitHub environment name resolved from the image manifest config for
          deployment reporting; empty when unconfigured.
        value: ${{ jobs.bump.outputs.environment }}
      environment_url:
        description: >-
          Deployment "View deployment" URL resolved from the image manifest
          config; empty when unconfigured.
        value: ${{ jobs.bump.outputs.environment_url }}
      branch_name:
        description: >-
          Branch name a pull_request-mode bump uses
          (odp-releaser/bump-<image_name>).
        value: ${{ jobs.bump.outputs.branch_name }}
      commit_message:
        description: Full commit message for the bump.
        value: ${{ jobs.bump.outputs.commit_message }}
      pr_title:
        description: Title for the bump pull request.
        value: ${{ jobs.bump.outputs.pr_title }}
      reviewers:
        description: >-
          Comma-separated GitHub usernames requested as reviewers on the bump
          pull request; empty when none are configured.
        value: ${{ jobs.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: ${{ jobs.bump.outputs.team_reviewers }}
      comment_enabled:
        description: >-
          Whether commenting back on the source pull request is enabled for this
          image ("true"/"false").
        value: ${{ jobs.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: ${{ jobs.bump.outputs.comment_pr_number }}
      comment_staged_template:
        description: >-
          Resolved comment template for a bump pull request awaiting review,
          unrendered.
        value: ${{ jobs.bump.outputs.comment_staged_template }}
      comment_deployed_template:
        description: Resolved comment template for a landed bump, unrendered.
        value: ${{ jobs.bump.outputs.comment_deployed_template }}
      sync_destinations:
        description: >-
          Newline-separated destination references the image was copied to;
          empty when no config asked for a sync.
        value: ${{ jobs.bump.outputs.sync_destinations }}
      synced:
        description: Whether the image sync actually ran ("true"/"false").
        value: ${{ jobs.bump.outputs.synced }}
    secrets:
      ci_app_id:
        description: >-
          Optional App ID of this repo's GitHub App. When set, the commit/PR is
          authored with an app token so it triggers this repo's own CI. Also
          needed for `team_reviewers` in the image manifest config: when that
          key is present, the app token is minted with organization "Members:
          read" added, so the app must be granted that permission.
        required: false
      ci_app_private_key:
        description: Optional private key matching ci_app_id.
        required: false
      reporter_apps:
        description: >-
          Optional JSON object mapping source owner -> {app_id, private_key}
          reporter app credentials, for deploy repos that receive dispatches
          from multiple source orgs. Also used to check `allowed_actors` team
          membership against source orgs (the app needs organization "Members:
          read" there).
        required: false
      reporter_app_id:
        description: >-
          Optional App ID of the source org's reporter GitHub App. When set
          (with reporter_app_private_key), a successful bump is reported back to
          the source repo as a GitHub deployment + status, and `allowed_actors`
          team membership is checked with it (the app needs organization
          "Members: read" for that). Commenting on the source pull request
          additionally needs "Pull requests: Read and write"; without it the
          comment step fails harmlessly and the deployment report still lands.
        required: false
      reporter_app_private_key:
        description: Optional private key matching reporter_app_id.
        required: false
      sync_registry: # group: Image sync
        description: >-
          Registry host to `docker login` to before syncing, for a destination
          that uses username/password auth (GHCR, Docker Hub). Leave unset when
          using sync_aws_role_arn, or when no config asks for a sync.
        required: false
      sync_username: # group: Image sync
        description: Username for sync_registry.
        required: false
      sync_password: # group: Image sync
        description: Password or token for sync_registry.
        required: false
      sync_source_registry: # group: Image sync
        description: >-
          Registry host to `docker login` to for the *source* side of a sync.
          Only needed when the image is pulled from somewhere that requires
          credentials the runner doesn't already have -- a private registry on a
          different host than the destination, or Docker Hub, where an anonymous
          pull shares the runner's IP rate limit. Since a failed copy fails the
          bump on purpose, an unauthenticated source is a source of intermittent
          release failures.
        required: false
      sync_source_username: # group: Image sync
        description: Username for sync_source_registry.
        required: false
      sync_source_password: # group: Image sync
        description: Password or token for sync_source_registry.
        required: false

permissions: {}

jobs:
  bump:
    name: Bump ${{ github.event.client_payload.image_name }} image
    runs-on: ubuntu-latest
    permissions:
      contents: write # commit the bump directly or push a PR branch
      pull-requests: write # open the bump pull request
      # Only actually used when sync_aws_role_arn is set, but `permissions`
      # cannot be conditional -- and a called workflow's permissions can only
      # be maintained or reduced, never elevated, relative to the caller. So
      # every caller has to grant this even if it never syncs; see the
      # "Upgrading" note in this file's header.
      id-token: write # assume the sync role via OIDC
    env:
      HAS_CI_APP: ${{ secrets.ci_app_id != '' }}
      HAS_REPORTER_APP:
        ${{ secrets.reporter_app_id != '' || secrets.reporter_apps != '' }}
      # The `secrets` context is not available in a step-level `if:`, so the
      # presence check has to be hoisted to the job env like the two above.
      HAS_SYNC_LOGIN: ${{ secrets.sync_registry != '' }}
      HAS_SYNC_SOURCE_LOGIN: ${{ secrets.sync_source_registry != '' }}
    concurrency:
      # client_payload.image_name is empty when this workflow is called
      # outside a repository_dispatch run (e.g. this repo's e2e CI); fall
      # back to a run-scoped group so unrelated runs never share a queue.
      group:
        odp-releaser-${{ github.event.client_payload.image_name || github.run_id
        }}
      cancel-in-progress: false
    outputs:
      image_name: ${{ steps.bump.outputs.image_name }}
      digest: ${{ steps.bump.outputs.digest }}
      changed: ${{ steps.bump.outputs.changed }}
      update_mode: ${{ steps.bump.outputs.update_mode }}
      environment: ${{ steps.bump.outputs.environment }}
      environment_url: ${{ steps.bump.outputs.environment_url }}
      branch_name: ${{ steps.bump.outputs.branch_name }}
      commit_message: ${{ steps.bump.outputs.commit_message }}
      pr_title: ${{ steps.bump.outputs.pr_title }}
      reviewers: ${{ steps.bump.outputs.reviewers }}
      team_reviewers: ${{ steps.bump.outputs.team_reviewers }}
      comment_enabled: ${{ steps.bump.outputs.comment_enabled }}
      comment_pr_number: ${{ steps.bump.outputs.comment_pr_number }}
      comment_staged_template: ${{ steps.bump.outputs.comment_staged_template }}
      comment_deployed_template:
        ${{ steps.bump.outputs.comment_deployed_template }}
      sync_destinations: ${{ steps.bump.outputs.sync_destinations }}
      synced: ${{ steps.bump.outputs.synced }}
    steps:
      - name: Generate app token
        id: app-token
        if: env.HAS_CI_APP == 'true'
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        with:
          client-id: ${{ secrets.ci_app_id }}
          private-key: ${{ secrets.ci_app_private_key }}
          # Scope the app token to only what the bump commit/PR needs.
          permission-contents: write
          permission-pull-requests: write

      - name: Checkout repository
        # persist-credentials stays true so the "Commit bump" step can git push.
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 # zizmor: ignore[artipacked]
        with:
          token: ${{ steps.app-token.outputs.token || github.token }}
          persist-credentials: true

      - name: Check the image manifest for team reviewers
        # Requesting a team review on the bump PR needs organization
        # "Members: read" on the token. Detect `team_reviewers` in the
        # manifest so the extra permission is only requested when the config
        # actually asks for team reviews — apps without the permission keep
        # working otherwise.
        id: team-config
        if: env.HAS_CI_APP == 'true'
        env:
          CONFIG_PATH: ${{ inputs.config_path }}
        run: |
          needed=false
          if [ -f "$CONFIG_PATH" ] && grep -Eq '^[^#]*\bteam_reviewers[[:space:]]*:' "$CONFIG_PATH"; then
            needed=true
          fi
          echo "needed=$needed" >> "$GITHUB_OUTPUT"

      - name: Generate app token with members read
        id: app-token-members
        if:
          env.HAS_CI_APP == 'true' && steps.team-config.outputs.needed == 'true'
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        with:
          client-id: ${{ secrets.ci_app_id }}
          private-key: ${{ secrets.ci_app_private_key }}
          permission-contents: write
          permission-pull-requests: write
          permission-members: read

      - name: Install ODP Releaser
        uses: $/.github/actions/install
        with:
          cache_suffix: odp-releaser-${{ job.workflow_sha }}

      # Registry logins for the image sync, both no-ops when unconfigured.
      # They run before the bump because the sync happens inside the
      # bump_images action, between writing the manifests and committing
      # them. skopeo picks these credentials up from
      # $HOME/.docker/config.json via the containers credential search
      # order, so no `skopeo login` is needed.
      - name: Configure AWS credentials for the sync
        if: inputs.sync_aws_role_arn != ''
        uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
        with:
          role-to-assume: ${{ inputs.sync_aws_role_arn }}
          aws-region: ${{ inputs.sync_aws_region }}

      - name: Log in to Amazon ECR for the sync
        if: inputs.sync_aws_role_arn != ''
        uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6

      - name: Log in to the sync registry
        if: env.HAS_SYNC_LOGIN == 'true'
        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ${{ secrets.sync_registry }}
          username: ${{ secrets.sync_username }}
          password: ${{ secrets.sync_password }}

      # The source side. The steps above authenticate the destination only,
      # but the copy also has to *pull*, and `sync_source_ref` names the
      # upstream registry -- which may be private, or may be Docker Hub, where
      # an anonymous pull is subject to the shared runner IP's rate limit.
      - name: Log in to the sync source registry
        if: env.HAS_SYNC_SOURCE_LOGIN == 'true'
        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ${{ secrets.sync_source_registry }}
          username: ${{ secrets.sync_source_username }}
          password: ${{ secrets.sync_source_password }}

      - name: Bump images
        id: bump
        uses: $/.github/actions/bump_images
        with:
          sync: ${{ inputs.sync }}
          client_payload:
            ${{ inputs.client_payload || toJSON(github.event.client_payload) }}
          config_path: ${{ inputs.config_path }}
          verbosity: ${{ inputs.verbosity }}
          git_user_name: ${{ inputs.git_user_name }}
          git_user_email: ${{ inputs.git_user_email }}
          dry_run: ${{ inputs.dry_run }}
          token:
            ${{ steps.app-token-members.outputs.token ||
            steps.app-token.outputs.token || github.token }}
          reporter_apps: ${{ secrets.reporter_apps }}
          reporter_app_id: ${{ secrets.reporter_app_id }}
          reporter_app_private_key: ${{ secrets.reporter_app_private_key }}

      - name: Bump commit URL
        # The bump_images action pushed the commit, so HEAD is the bump
        # commit; capture its URL as the deployment's "View deployment" link
        # fallback for commit-mode bumps.
        id: commit-url
        if:
          env.HAS_REPORTER_APP == 'true' && inputs.dry_run != true &&
          steps.bump.outputs.changed == 'true' && steps.bump.outputs.update_mode
          == 'commit'
        run: |
          printf 'url=%s/%s/commit/%s\n' \
            "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$(git rev-parse HEAD)" \
            >> "$GITHUB_OUTPUT"

      - name: Report deployment to source repo
        # Best effort: a failed report never fails the bump itself.
        if:
          env.HAS_REPORTER_APP == 'true' && inputs.dry_run != true &&
          steps.bump.outputs.changed == 'true'
        continue-on-error: true
        uses: $/.github/actions/report_deployment
        with:
          client_payload:
            ${{ inputs.client_payload || toJSON(github.event.client_payload) }}
          update_mode: ${{ steps.bump.outputs.update_mode }}
          environment: ${{ steps.bump.outputs.environment }}
          environment_url:
            ${{ steps.bump.outputs.environment_url ||
            steps.bump.outputs.pull_request_url || steps.commit-url.outputs.url
            }}
          verbosity: ${{ inputs.verbosity }}
          reporter_apps: ${{ secrets.reporter_apps }}
          reporter_app_id: ${{ secrets.reporter_app_id }}
          reporter_app_private_key: ${{ secrets.reporter_app_private_key }}

      - name: Comment on source pull request
        # Best effort, like the deployment report above: a failed comment never
        # fails the bump. Skipped outright when the image manifest turned
        # commenting off, or when the triggering event carried no source pull
        # request to comment on (only push events do).
        if:
          env.HAS_REPORTER_APP == 'true' && inputs.dry_run != true &&
          steps.bump.outputs.changed == 'true' &&
          steps.bump.outputs.comment_enabled == 'true' &&
          steps.bump.outputs.comment_pr_number != ''
        continue-on-error: true
        uses: $/.github/actions/comment_on_pr
        with:
          client_payload:
            ${{ inputs.client_payload || toJSON(github.event.client_payload) }}
          update_mode: ${{ steps.bump.outputs.update_mode }}
          environment: ${{ steps.bump.outputs.environment }}
          environment_url: ${{ steps.bump.outputs.environment_url }}
          # Where the bump landed: the pull request for a PR-mode bump, the
          # bump commit for a direct one.
          bump_url:
            ${{ steps.bump.outputs.pull_request_url ||
            steps.commit-url.outputs.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 }}
          verbosity: ${{ inputs.verbosity }}
          reporter_apps: ${{ secrets.reporter_apps }}
          reporter_app_id: ${{ secrets.reporter_app_id }}
          reporter_app_private_key: ${{ secrets.reporter_app_private_key }}

Follow-up jobs in the calling workflow can consume the outputs, e.g.:

jobs:
  bump:
    uses: gulfofmaine/odp-releaser/.github/workflows/bump-images.yml@<sha-or-tag>

  report:
    needs: [bump]
    if: needs.bump.outputs.changed == 'true'
    runs-on: ubuntu-latest
    steps:
      - env:
          IMAGE_NAME: ${{ needs.bump.outputs.image_name }}
          DIGEST: ${{ needs.bump.outputs.digest }}
        run: echo "Bumped $IMAGE_NAME to $DIGEST"

To insert steps between the bump and the commit/PR (e.g. syncing the image to another registry yourself), use the bump_images composite action with stage_only: "true" instead of this workflow.

commit vs pull_request

Each image in .github/image_manifest.yaml sets update_mode: commit (default) or update_mode: pull_request per ImageConfig. In commit mode the workflow pushes the manifest edits straight to the checked-out branch (normally the default branch); in pull_request mode it opens (or updates) a pull request on a stable branch named odp-releaser/bump-<image_name> via peter-evans/create-pull-request.

Requesting reviewers on bump pull requests

pull_request-mode bumps can request reviews: set reviewers (GitHub usernames) and/or team_reviewers (team slugs, no org prefix as they need to be in the parent org of the deployment repo) — per image config, or under defaults: to apply to every config. A config's own list replaces the default (an explicit [] requests none); when several matching configs disagree, the first in config order wins with a warning, mirroring how environment resolves.

Requesting a team review needs a token with organization "Members: read" (the default GITHUB_TOKEN can't do it). The workflow handles this automatically: when the checked-out image manifest contains a team_reviewers key, the ci_app_id app token is minted with that permission added (grant the app the organization "Members: read" permission first; without ci_app_* secrets team reviews can't be requested). One upstream caveat from peter-evans/create-pull-request: a requested reviewer who is the PR's author causes the request-review call to fail.

The ci_app_* PR-CI-triggering note

GitHub Actions deliberately does not trigger further workflow runs from a commit or pull request authored with the default GITHUB_TOKEN. If any of your images use update_mode: pull_request, that means your own CI would never run against the bump PR unless the commit/PR is authored with a GitHub App token instead. Passing ci_app_id / ci_app_private_key — your deploy org's own dispatch app credentials — makes the workflow mint that token before checkout, so the pushed commit and/or opened PR is authored by your app and does trigger CI. See GitHub App for how to obtain and wire those credentials.

Reporting deployments back to the source repo

When the reporter_app_id / reporter_app_private_key (or reporter_apps) secrets are set, the report_deployment composite action runs after a successful bump. It creates a GitHub deployment on the source repository at the commit that built the image (client_payload.git_sha) and sets its status, so the source repo's pull request timeline and Environments sidebar show where the image went.

  • The deployment state mirrors what happened on the deploy side:

    • success when the bump was committed directly
    • queued when a bump pull request was opened but not yet merged — call report-merged.yml from the deploy repo to flip it to success once the bump PR merges.

    Note

    This records that the manifest change landed — whether ArgoCD has synced it to a cluster is downstream of this tool.

  • The environment name defaults to the deploy repo's owner/name slug; set environment in .github/image_manifest.yaml — per image config, or under defaults: as a repo-wide default — to override it.

  • The "View deployment" link defaults to the bump commit (commit mode) or the bump pull request (pull_request mode); set environment_url in the image manifest config — again per image config or under defaults: — to point it at the running app instead (templated with {new_tag}, {git_sha}, and {digest}). The logs link points at the bump workflow run.
  • Reporting is best-effort: the step runs with continue-on-error, so a failed report never fails the bump itself.

The credentials belong to a reporter app with Deployments: Read and write installed on the source repos — normally a single app owned by the deploy org and installed by each source org. See GitHub App for how to set one up.

Commenting on the source pull request

If that same reporter app has additionally been granted Pull requests: Read and write, the comment_on_pr composite action runs after the deployment report and posts a comment on the source pull request naming the image, the tag and the environment in words — the readable counterpart to the deployment record.

  • The state decides which template is used:
    • staged for a pull_request-mode bump still awaiting review
    • deployed for one that has landed. Calling report-merged.yml rewrites the staged comment as deployed when the bump PR merges.
  • The templates come from the image manifest config, inherited field by field from defaults: and then the built-ins — see Pull request comments.
  • The step is skipped entirely when commenting is disabled for the image, or when the event carried no source pull request (only push events do), and is otherwise best-effort like the deployment report.
  • The comment token is minted separately from the deployment one, with pull_requests: write only, so a source org that hasn't accepted the comment permission keeps receiving deployment reports.

Allowed source repos and actors

Every dispatch carries client_payload.repo — the source repo's owner/name slug — and client_payload.source.actor — the GitHub user who triggered the source build — as its identifiers for "who sent this" (see Client Payload). A deploy repo's .github/image_manifest.yaml can restrict both per ImageConfig:

  • allowed_source_repos: trusted owner/name slugs.
  • allowed_actors: a mapping with users (GitHub usernames, compared case-insensitively) and/or teams (org/team-slug entries). Teams live in the source orgs, so membership is checked with the same reporter app credentials (reporter_apps / reporter_app_id / reporter_app_private_key) that deployment reporting uses — grant the reporter app the organization "Members: read" permission for this.

Both can also be set under defaults: to apply to every config; a config's own value replaces the default entirely (an empty list denies everyone). Leaving a resolved value unset disables that check.

A config whose allowlists reject the payload is skipped with a warning, so other configs for the same image can still apply — e.g. anyone may bump a dev overlay while only release managers reach production. When every event-matched config for the image rejects the payload, bump-images fails (non-zero exit, no manifest changes) so unauthorized attempts are loud.

To share allowlists between configs, use YAML anchors and merge keys — top-level x- keys are ignored by the schema:

x-prod-guards: &prod-guards
  allowed_source_repos: [gulfofmaine/Neracoos-1-Buoy-App]
  allowed_actors:
    users: [abkfenris]
    teams: [gulfofmaine/deployers]

images:
  gmri/neracoos-mariners-dashboard:
    - <<: *prod-guards
      events: [release]
      kustomize_manifests:
        - ../apps/mariners/kustomization.yaml

This is the deploy repo's own defense-in-depth check, independent of which source orgs the deploy org's dispatch app trusts — see Image manifest for the fields and GitHub Apps for the credential-level trust boundary.