Skip to content

Commit a8e5a8b

Browse files
monotekclaude
andcommitted
feat(gitops): add annotate-only mode for Flux image automation
When a gitops-dev/stage/prod line provides a path but no field, update_file now writes the deploy.staffbase.com/* annotations without updating the image tag. The image-update block is guarded on a non-empty field; annotation writes stay unconditional. This supports apps migrating from the apperator Application CR to the staffbase-application Helm chart, where the image tag is owned by Flux image automation (ImageRepository/ImagePolicy/ImageUpdateAutomation). The action must stop writing the tag for those apps but keep stamping commitSha and repositoryFullName, which image automation never knows. The two-token "<path> <field>" form is unchanged (image update + annotations). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2ac9c02 commit a8e5a8b

3 files changed

Lines changed: 109 additions & 14 deletions

File tree

README.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,33 @@ Whenever the action updates a GitOps file, it stamps the following annotations o
118118

119119
These keys mirror the [Swarmia Deployment API](https://help.swarmia.com/settings/organization/configuring-deployments-in-swarmia) field names and are read by `flux-deployment-reporter` to report deployments to Swarmia once Flux finishes reconciling.
120120

121+
### Annotate-only mode (Flux image automation)
122+
123+
Each `gitops-dev`/`gitops-stage`/`gitops-prod` line is normally `<path> <field>`, where
124+
`<field>` is the yq path of the image reference to update. **Omit the field** (provide a
125+
path only) and the action will write the `deploy.staffbase.com/*` annotations **without
126+
touching the image tag**:
127+
128+
```yaml
129+
gitops-stage: |-
130+
kubernetes/namespaces/my-service/stage/de1/my-service-helm.yaml
131+
```
132+
133+
Use this for apps whose image tag is owned by
134+
[Flux image automation](https://fluxcd.io/flux/components/image/) (ImageRepository /
135+
ImagePolicy / ImageUpdateAutomation), which scans the registry and commits the `tag:`
136+
line back to the GitOps repo itself. The action still needs to stamp the annotations,
137+
because image automation only knows the tag — it never knows the source `commitSha` or
138+
`repositoryFullName` that Swarmia needs for DORA metrics. This also yields a full commit
139+
SHA in every environment, including prod, where image tags (e.g. `2025.50.14`) carry no SHA.
140+
141+
> **Note:** the `version` annotation is set to the freshly built tag, so it may briefly
142+
> lead the actual `tag:` until image automation selects the new build. It converges, and
143+
> the reporter dedupes on `(commitSha, version)`, so it self-heals.
144+
145+
The two-token `<path> <field>` form is unchanged: it updates the image **and** writes the
146+
annotations, exactly as before.
147+
121148
## Inputs
122149

123150
| Name | Description | Default |
@@ -143,9 +170,9 @@ These keys mirror the [Swarmia Deployment API](https://help.swarmia.com/settings
143170
| `gitops-user` | GitHub User for GitOps | `Staffbot` |
144171
| `gitops-email` | GitHub Email for GitOps | `staffbot@staffbase.com` |
145172
| `gitops-token` | GitHub Token for GitOps | |
146-
| `gitops-dev` | Files which should be updated by the GitHub Action for DEV, must be relative to the root of the GitOps repository | |
147-
| `gitops-stage` | Files which should be updated by the GitHub Action for STAGE, must be relative to the root of the GitOps repository | |
148-
| `gitops-prod` | Files which should be updated by the GitHub Action for PROD, must be relative to the root of the GitOps repository | |
173+
| `gitops-dev` | Files which should be updated by the GitHub Action for DEV, must be relative to the root of the GitOps repository. Each line is `<path> <field>`; omit `<field>` (path only) to write annotations without touching the image — see [Annotate-only mode](#annotate-only-mode-flux-image-automation) | |
174+
| `gitops-stage` | Files which should be updated by the GitHub Action for STAGE, must be relative to the root of the GitOps repository. Each line is `<path> <field>`; omit `<field>` (path only) for [annotate-only mode](#annotate-only-mode-flux-image-automation) | |
175+
| `gitops-prod` | Files which should be updated by the GitHub Action for PROD, must be relative to the root of the GitOps repository. Each line is `<path> <field>`; omit `<field>` (path only) for [annotate-only mode](#annotate-only-mode-flux-image-automation) | |
149176
| `working-directory` | The directory in which the GitOps action should be executed. The docker-file variable should be relative to working directory. | `.` |
150177

151178
## Outputs

scripts/lib/gitops-functions.sh

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,23 @@ update_file() {
3333
local field="$2"
3434
local image="$3"
3535

36-
echo "Check if path ${file} ${field} exists and get old current version"
37-
yq -e ."${field}" "${file}"
38-
echo "Run update ${file} ${field} ${image}"
39-
if [[ "${field}" == *.tag ]]; then
40-
yq -i ".${field}=\"${INPUT_TAG}\"" "${file}"
41-
else
42-
local field_type
43-
field_type=$(yq "(.${field} | type)" "${file}" 2>/dev/null || echo "!!null")
44-
if [[ "${field_type}" == "!!map" ]] && yq -e ".${field} | (has(\"tag\") or has(\"repository\"))" "${file}" > /dev/null 2>&1; then
45-
yq -i ".${field}.tag=\"${INPUT_TAG}\"" "${file}"
36+
if [[ -n "$field" ]]; then
37+
echo "Check if path ${file} ${field} exists and get old current version"
38+
yq -e ."${field}" "${file}"
39+
echo "Run update ${file} ${field} ${image}"
40+
if [[ "${field}" == *.tag ]]; then
41+
yq -i ".${field}=\"${INPUT_TAG}\"" "${file}"
4642
else
47-
yq -i ."${field}"=\""${image}"\" "${file}"
43+
local field_type
44+
field_type=$(yq "(.${field} | type)" "${file}" 2>/dev/null || echo "!!null")
45+
if [[ "${field_type}" == "!!map" ]] && yq -e ".${field} | (has(\"tag\") or has(\"repository\"))" "${file}" > /dev/null 2>&1; then
46+
yq -i ".${field}.tag=\"${INPUT_TAG}\"" "${file}"
47+
else
48+
yq -i ."${field}"=\""${image}"\" "${file}"
49+
fi
4850
fi
51+
else
52+
echo "No field for ${file}; image tag owned by Flux image automation — writing annotations only"
4953
fi
5054

5155
echo "Writing deployment annotations to ${file}"

tests/lib-gitops-functions.bats

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,63 @@ EOF
147147
! grep -qE 'deploy\.staffbase\.com/(repo|sha)"' "${TEST_TEMP_DIR}/yq_calls.log"
148148
}
149149

150+
# --- update_file: annotate-only mode (empty field) ---
151+
152+
@test "update_file with empty field skips image update" {
153+
update_file "helmrelease.yaml" "" ""
154+
# the field-existence check (yq -e .<field>) only runs in field mode
155+
! grep -q 'yq -e .' "${TEST_TEMP_DIR}/yq_calls.log"
156+
# no image/tag assignment written
157+
! grep -qE 'yq -i \.[^ ]*=' "${TEST_TEMP_DIR}/yq_calls.log"
158+
}
159+
160+
@test "update_file with empty field still writes all three annotations" {
161+
update_file "helmrelease.yaml" "" ""
162+
grep -q 'deploy.staffbase.com/repositoryFullName' "${TEST_TEMP_DIR}/yq_calls.log"
163+
grep -q 'deploy.staffbase.com/commitSha' "${TEST_TEMP_DIR}/yq_calls.log"
164+
grep -q "deploy.staffbase.com/version.*${INPUT_TAG}" "${TEST_TEMP_DIR}/yq_calls.log"
165+
}
166+
167+
@test "INTEGRATION: empty field annotates without touching the image tag" {
168+
rm -rf "${TEST_TEMP_DIR}/mocks"
169+
skip_if_no_yq
170+
local test_file="${TEST_TEMP_DIR}/helmrelease.yaml"
171+
cp "${BATS_TEST_DIRNAME}/fixtures/helmrelease.yaml" "$test_file"
172+
173+
update_file "$test_file" "" ""
174+
175+
# image tag is unchanged
176+
run yq '.spec.values.workload.container.image.tag' "$test_file"
177+
assert_output "placeholder"
178+
179+
# annotations are stamped
180+
run yq '.metadata.annotations["deploy.staffbase.com/repositoryFullName"]' "$test_file"
181+
assert_output "$GITHUB_REPOSITORY"
182+
run yq '.metadata.annotations["deploy.staffbase.com/commitSha"]' "$test_file"
183+
assert_output "$GITHUB_SHA"
184+
run yq '.metadata.annotations["deploy.staffbase.com/version"]' "$test_file"
185+
assert_output "$INPUT_TAG"
186+
}
187+
188+
@test "INTEGRATION: provided field updates the tag AND annotates" {
189+
rm -rf "${TEST_TEMP_DIR}/mocks"
190+
skip_if_no_yq
191+
local test_file="${TEST_TEMP_DIR}/helmrelease.yaml"
192+
cp "${BATS_TEST_DIRNAME}/fixtures/helmrelease.yaml" "$test_file"
193+
194+
update_file "$test_file" "spec.values.workload.container.image" "$IMAGE"
195+
196+
# image tag updated
197+
run yq '.spec.values.workload.container.image.tag' "$test_file"
198+
assert_output "$INPUT_TAG"
199+
200+
# annotations also stamped
201+
run yq '.metadata.annotations["deploy.staffbase.com/commitSha"]' "$test_file"
202+
assert_output "$GITHUB_SHA"
203+
run yq '.metadata.annotations["deploy.staffbase.com/version"]' "$test_file"
204+
assert_output "$INPUT_TAG"
205+
}
206+
150207
# --- commit_changes ---
151208

152209
@test "commit_changes commits and pushes when push is true" {
@@ -196,6 +253,13 @@ file2.yaml spec.image"
196253
[[ "$yq_count" -eq 2 ]]
197254
}
198255

256+
@test "process_file_updates handles path-only line (annotate only)" {
257+
process_file_updates "file1.yaml" "false"
258+
# no field check performed, but annotations written
259+
! grep -q 'yq -e .' "${TEST_TEMP_DIR}/yq_calls.log"
260+
grep -q 'deploy.staffbase.com/commitSha' "${TEST_TEMP_DIR}/yq_calls.log"
261+
}
262+
199263
@test "process_file_updates commits when should_commit is true" {
200264
process_file_updates "file1.yaml spec.image" "true"
201265
grep -q 'git commit' "${TEST_TEMP_DIR}/git_calls.log"

0 commit comments

Comments
 (0)