HOMELAB-1681: ci(cockpit): workflow_dispatch build path for pre-live image tags #580

Merged
aaron merged 1 commit from plane/HOMELAB-1681b-image-dispatch into epic/LD3 2026-10-02 07:11:49 +00:00
Owner

What

The LD3.1b deploy slice (ratified: .rpiv/artifacts/designs/ld3/ratification-package-3.1b.md) pins the cockpit image by digest. The cockpit image workflow's only push trigger is live, which epic/LD3 has not reached — so the digest cannot exist yet. This PR adds the dispatch path that publishes an epic/LD3 build, and it is a prerequisite of the prod deploy PR.

Why not just merge the epic first

The deploy slice is a prod → live PR (prod targets live directly), so it can land before the epic merges. What it cannot do is name an image that was never built.

The change — additive by construction

Element Before After Behaviour on the pre-existing paths
workflow_dispatch {} one optional ref input, default: "" n/a (it was a bare trigger)
new step "Check out the requested ref (dispatch only)" in both jobs — shallow git fetch --depth=1 origin <ref> + checkout --detach FETCH_HEAD, no-op when the input is empty no-op: push/pull_request never set the input, and a dispatch with an empty input builds exactly what actions/checkout already holds
SHORT_SHA ${{ github.sha }} | cut -c1-7 git rev-parse HEAD | cut -c1-7 identical seven characters (checkout HEAD is github.sha on push/PR)
latest promotion if GITHUB_REF = refs/heads/live case "${DISPATCH_REF:-$GITHUB_REF}" accepting live/refs/heads/live identical string when no input is set → a push to live still promotes and nothing else does

Byte-identical and untouched: both existing trigger blocks (branches/paths), the if: github.event_name != 'pull_request' push gate, the scoped HARBOR_PUSH_USERNAME/HARBOR_PUSH_PASSWORD robot (no admin credential, no new secret, no new privilege), the SHA-tag-only push, the size gate, all three smoke tests, IMAGE/DOCKERFILE/CONTEXT.

Why SHORT_SHA moved to git rev-parse HEAD. With a ref input, github.sha is the run's commit, not the built one. The tag, the smoke results and the digest echoed at the end must all describe the same commit, or the digest handed to the deploy slice would name something other than what was tested. This is the one place the fix is not purely additive, and it is a no-op on the existing paths.

Why the promotion guard moved to the built ref. A dispatch from live carrying ref: epic/LD3 builds epic code; with the old guard (GITHUB_REF = refs/heads/live) it would have moved latest onto an unmerged epic. The header's promotion rule says latest only moves from live, and this makes that statement true of the built ref rather than of the run's ref.

Verification

  • python3 -c "yaml.safe_load(...)" — parses; triggers/inputs inspected programmatically.
  • Every run: block (GH expressions substituted like the runner does) passes bash -n; the new/changed blocks pass shellcheck -S warning clean.
  • yamllint finding count unchanged at 67 — the file's pre-existing line-length/style level; no finding of a new class.
  • Diff confirms no changed line touches secrets., branches:, paths:, runs-on: or env.IMAGE (the only added env: keys are BUILD_REF and DISPATCH_REF).

What this does NOT do

  • Does not build anything by itself — the orchestrator dispatches the run after merge.
  • Does not move latest (only a live build can).
  • Does not touch the Dockerfile, the smoke suite, the size gate or any other workflow.

Land order (LD3.1b)

  1. this PR (dispatch path) →
  2. dispatch the build on epic/LD3 → read the digest →
  3. prod deploy PR with PIN_AFTER_BUILD replaced by that digest →
  4. Zitadel app + operator grant PR (infra-core core/terraform/live/zitadel/) + KV seeding (human).
## What The LD3.1b deploy slice (ratified: `.rpiv/artifacts/designs/ld3/ratification-package-3.1b.md`) pins the cockpit image **by digest**. The cockpit image workflow's only push trigger is `live`, which epic/LD3 has not reached — so the digest cannot exist yet. This PR adds the dispatch path that publishes an epic/LD3 build, and it is a **prerequisite of the prod deploy PR**. ## Why not just merge the epic first The deploy slice is a `prod → live` PR (prod targets `live` directly), so it can land before the epic merges. What it cannot do is name an image that was never built. ## The change — additive by construction | Element | Before | After | Behaviour on the pre-existing paths | |---|---|---|---| | `workflow_dispatch` | `{}` | one optional `ref` input, `default: ""` | n/a (it was a bare trigger) | | new step *"Check out the requested ref (dispatch only)"* in **both** jobs | — | shallow `git fetch --depth=1 origin <ref>` + `checkout --detach FETCH_HEAD`, no-op when the input is empty | no-op: `push`/`pull_request` never set the input, and a dispatch with an empty input builds exactly what `actions/checkout` already holds | | `SHORT_SHA` | `${{ github.sha }} \| cut -c1-7` | `git rev-parse HEAD \| cut -c1-7` | identical seven characters (checkout HEAD **is** `github.sha` on push/PR) | | `latest` promotion | `if GITHUB_REF = refs/heads/live` | `case "${DISPATCH_REF:-$GITHUB_REF}"` accepting `live`/`refs/heads/live` | identical string when no input is set → a `push` to `live` still promotes and nothing else does | Byte-identical and untouched: both existing trigger blocks (`branches`/`paths`), the `if: github.event_name != 'pull_request'` push gate, the scoped `HARBOR_PUSH_USERNAME`/`HARBOR_PUSH_PASSWORD` robot (no admin credential, no new secret, no new privilege), the SHA-tag-only push, the size gate, all three smoke tests, `IMAGE`/`DOCKERFILE`/`CONTEXT`. **Why `SHORT_SHA` moved to `git rev-parse HEAD`.** With a `ref` input, `github.sha` is the *run's* commit, not the built one. The tag, the smoke results and the digest echoed at the end must all describe the same commit, or the digest handed to the deploy slice would name something other than what was tested. This is the one place the fix is not purely additive, and it is a no-op on the existing paths. **Why the promotion guard moved to the built ref.** A dispatch from `live` carrying `ref: epic/LD3` builds epic code; with the old guard (`GITHUB_REF` = `refs/heads/live`) it would have moved `latest` onto an unmerged epic. The header's promotion rule says `latest` only moves from `live`, and this makes that statement true of the *built* ref rather than of the *run's* ref. ## Verification - `python3 -c "yaml.safe_load(...)"` — parses; triggers/inputs inspected programmatically. - Every `run:` block (GH expressions substituted like the runner does) passes `bash -n`; the new/changed blocks pass `shellcheck -S warning` clean. - `yamllint` finding count **unchanged at 67** — the file's pre-existing line-length/style level; no finding of a new class. - Diff confirms no changed line touches `secrets.`, `branches:`, `paths:`, `runs-on:` or `env.IMAGE` (the only added `env:` keys are `BUILD_REF` and `DISPATCH_REF`). ## What this does NOT do - Does not build anything by itself — the orchestrator dispatches the run after merge. - Does not move `latest` (only a `live` build can). - Does not touch the Dockerfile, the smoke suite, the size gate or any other workflow. ## Land order (LD3.1b) 1. this PR (dispatch path) → 2. dispatch the build on epic/LD3 → read the digest → 3. `prod` deploy PR with `PIN_AFTER_BUILD` replaced by that digest → 4. Zitadel app + `operator` grant PR (infra-core `core/terraform/live/zitadel/`) + KV seeding (human).
HOMELAB-1681: ci(cockpit): workflow_dispatch build path for pre-live image tags
All checks were successful
0/0 projects applied successfully.
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 21s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 1m8s
CI Review / cockpit-test (pull_request) Successful in 2m53s
CI Review / dispatcher-test (pull_request) Successful in 3m24s
CI Review / cockpit-e2e (pull_request) Successful in 5m54s
9a874cb296
The deploy slice (LD3.1b) pins a DIGEST, so the digest has to exist before the
manifests can name it — and the cockpit image workflow's only push trigger is
`live`, which the epic has not reached. This adds the dispatch path that
publishes an epic/LD3 build.

ADDITIVE, by construction:

- `workflow_dispatch` gains one optional `ref` input (default "" = build the
  ref the dispatch ran on, i.e. the branch picked in the UI). A new step in
  BOTH jobs checks out a non-empty `ref` with a shallow single-ref fetch and
  `--detach`, so preflight answers "is the cockpit on THIS ref?" for the ref
  that will actually be built.
- `SHORT_SHA` now comes from `git rev-parse HEAD` instead of
  `${{ github.sha }}`. Same seven characters on the push/PR paths (checkout
  HEAD IS github.sha there); on the dispatch path it is the BUILT commit, so
  the tag, the smoke results and the reported digest all describe one commit.
- The `latest` promotion decision reads the BUILT ref rather than the run's
  ref. With no input the two strings are identical, so a `push` to `live` still
  promotes and nothing else does; the case this closes is a dispatch from
  `live` whose `ref` input names an epic branch, which must never move
  `latest`.

Unchanged: both existing triggers byte-for-byte (branches/paths), the
`if: github.event_name != 'pull_request'` push gate, the scoped
HARBOR_PUSH_USERNAME/PASSWORD robot (no admin credential, no new secret), the
SHA-tag-only push, and the size gate.

Verified: YAML parses (PyYAML); every `run:` block passes `bash -n`;
yamllint finding count unchanged at the file's pre-existing level (67
line-length/style findings before and after, none of a new class).
Author
Owner

Landing review — verdict: APPROVE-WITH-CONCERNS (no blockers)

Reviewed by a fresh, independent reviewer (separate process, primary sources only: the PR diff, both full workflow revisions via git show, the ticket acceptance criteria). Head sha reviewed: 9a874cb296d74f34ee3b754261276d53ac986c5f. Combined commit status on that sha: success (9 statuses, incl. CI Review / cockpit-e2e, dispatcher-test, cockpit-test, Build Lab Director Cockpit Image / build-and-push).

Criteria check — all PASS

# Criterion Verdict Evidence
1 Purely additive PASS 7 hunks, 1 file changed, 100 insertions(+), 6 deletions(-); parsed on dict equal to before except workflow_dispatch; job needs/if/runs-on identical, step lists differ only by the added dispatch step in each job
2 No new credentials PASS secrets. refs still exactly HARBOR_PUSH_USERNAME / HARBOR_PUSH_PASSWORD; new env: keys are only BUILD_REF and DISPATCH_REF
3 Branch-event push semantics unchanged PASS on.push byte-equal; on push/PR github.event.inputs is absent → BUILD_REF="${GITHUB_REF}" → refs/heads/live promotes, PR refs fall to *); no docker tag/docker push other than :${SHORT_SHA} and the :latest alias
4 Dispatch builds the given ref, SHA tag only PASS github.event.inputs.ref proven to resolve through Forgejo workflow_dispatch → runner Event payload; empty input = documented exit 0 no-op; non-empty = shallow fetch + checkout --detach FETCH_HEAD before Set image variables; nonexistent ref exits 128 loudly
5 SHORT_SHA / latest-guard changes justified PASS Both are required and both are no-ops on the old paths — see below

Why the two non-additive lines are justified. SHORT_SHA had to move to git rev-parse HEAD: the runner sets github.sha from Event["after"] (absent for a dispatch) and otherwise from the run's ref, never from the ref input — so with the old expression the tag, the smoke results and the read-back digest would describe a different commit than the one built. The latest guard had to key on the built ref: a dispatch attached to refs/heads/live carrying ref: epic/LD3 would otherwise have moved latest onto unmerged epic code.

Concerns (non-blocking, posted for the record)

  1. git fetch --depth=1 origin "${BUILD_REF}" passes dispatch input without --. A ref beginning with - is parsed by git as an option (--all → fatal: fetch --all does not take a repository argument) and a multi-entry FETCH_HEAD then makes checkout --detach FETCH_HEAD succeed, so a dash-leading input builds FETCH_HEAD's first entry instead of the named ref. Plain bogus refs still exit 128 loudly, and dispatch requires repo write permission, so this is hardening, not escalation: git fetch --depth=1 origin -- "${BUILD_REF}" or a git check-ref-format guard.
  2. This workflow is not on the default branch (main), so it will not appear in the Actions UI picker on Forgejo 14.0.3 — dispatch is reachable only through the API (GetWorkflowFromCommit(git, opt.Ref, name) reads the file from the supplied ref). That is the intended route; worth a line in the runbook so nobody hunts for a button.
  3. A dispatch whose run ref is an epic branch but whose ref input is live will promote latest. Correct by construction (the built commit is live's head), but it is a newly available human-triggered route to latest; the header prose could say so.

No blocker findings → proceeding to merge per the landing contract.

## Landing review — verdict: APPROVE-WITH-CONCERNS (no blockers) Reviewed by a fresh, independent reviewer (separate process, primary sources only: the PR diff, both full workflow revisions via `git show`, the ticket acceptance criteria). Head sha reviewed: `9a874cb296d74f34ee3b754261276d53ac986c5f`. Combined commit status on that sha: **success** (9 statuses, incl. `CI Review / cockpit-e2e`, `dispatcher-test`, `cockpit-test`, `Build Lab Director Cockpit Image / build-and-push`). **Criteria check — all PASS** | # | Criterion | Verdict | Evidence | |---|---|---|---| | 1 | Purely additive | PASS | 7 hunks, `1 file changed, 100 insertions(+), 6 deletions(-)`; parsed `on` dict equal to before except `workflow_dispatch`; job `needs`/`if`/`runs-on` identical, step lists differ only by the added dispatch step in each job | | 2 | No new credentials | PASS | `secrets.` refs still exactly `HARBOR_PUSH_USERNAME` / `HARBOR_PUSH_PASSWORD`; new `env:` keys are only `BUILD_REF` and `DISPATCH_REF` | | 3 | Branch-event push semantics unchanged | PASS | `on.push` byte-equal; on push/PR `github.event.inputs` is absent → `BUILD_REF="${GITHUB_REF}"` → `refs/heads/live` promotes, PR refs fall to `*)`; no `docker tag`/`docker push` other than `:${SHORT_SHA}` and the `:latest` alias | | 4 | Dispatch builds the given ref, SHA tag only | PASS | `github.event.inputs.ref` proven to resolve through Forgejo `workflow_dispatch` → runner `Event` payload; empty input = documented `exit 0` no-op; non-empty = shallow fetch + `checkout --detach FETCH_HEAD` before `Set image variables`; nonexistent ref exits 128 loudly | | 5 | `SHORT_SHA` / `latest`-guard changes justified | PASS | Both are required and both are no-ops on the old paths — see below | **Why the two non-additive lines are justified.** `SHORT_SHA` had to move to `git rev-parse HEAD`: the runner sets `github.sha` from `Event["after"]` (absent for a dispatch) and otherwise from the run's ref, never from the `ref` input — so with the old expression the tag, the smoke results and the read-back digest would describe a different commit than the one built. The `latest` guard had to key on the built ref: a dispatch attached to `refs/heads/live` carrying `ref: epic/LD3` would otherwise have moved `latest` onto unmerged epic code. **Concerns (non-blocking, posted for the record)** 1. `git fetch --depth=1 origin "${BUILD_REF}"` passes dispatch input without `--`. A `ref` beginning with `-` is parsed by git as an *option* (`--all` → `fatal: fetch --all does not take a repository argument`) and a multi-entry `FETCH_HEAD` then makes `checkout --detach FETCH_HEAD` succeed, so a dash-leading input builds `FETCH_HEAD`'s first entry instead of the named ref. Plain bogus refs still exit 128 loudly, and dispatch requires repo write permission, so this is hardening, not escalation: `git fetch --depth=1 origin -- "${BUILD_REF}"` or a `git check-ref-format` guard. 2. This workflow is not on the default branch (`main`), so it will not appear in the Actions UI picker on Forgejo 14.0.3 — dispatch is reachable only through the API (`GetWorkflowFromCommit(git, opt.Ref, name)` reads the file from the supplied ref). That is the intended route; worth a line in the runbook so nobody hunts for a button. 3. A dispatch whose run ref is an epic branch but whose `ref` input is `live` will promote `latest`. Correct by construction (the built commit *is* live's head), but it is a newly available human-triggered route to `latest`; the header prose could say so. No blocker findings → proceeding to merge per the landing contract.
aaron merged commit 65e357ff9a into epic/LD3 2026-10-02 07:11:49 +00:00
Author
Owner

Landed + GitOps continuation

  • Merged: 2026-10-02T07:11:49Z, merge sha 65e357ff9a5fcd132edc2bb31b597408edefa621 (now epic/LD3 head), branch deleted.
  • Dispatch: POST .../actions/workflows/cockpit-image-build.yaml/dispatches {"ref":"epic/LD3"} → 204.
  • Run #1721 (api id 6468, event workflow_dispatch, refs/heads/epic/LD3) → success, preflight 4s + build-and-push 1m23s. Log: no ref input: building 65e357ff9a… → Building …cockpit:65e357f → Image size OK. → smoke PASS → ref refs/heads/epic/LD3 is not live; …:latest untouched → Pushed: …cockpit@sha256:a248f426….

Digest to pin in the 3.1b deploy slice (PR #878):

harbor.aaron.reynoza.org/lab-director/cockpit@sha256:a248f426d0a4bf685c7134401e8d2c851440a588f566ef724936c1094fa3bb4d

tag: harbor.aaron.reynoza.org/lab-director/cockpit:65e357f

crane verification: crane digest …:65e357f → sha256:a248f426… (matches the log); crane ls → exactly one tag 65e357f; crane digest …:latest → 404 not found (crane ls has no latest) — nothing created or moved; image created 2026-10-02T07:13:15Z, inside the run window.

## Landed + GitOps continuation - **Merged**: 2026-10-02T07:11:49Z, merge sha **`65e357ff9a5fcd132edc2bb31b597408edefa621`** (now `epic/LD3` head), branch deleted. - **Dispatch**: `POST .../actions/workflows/cockpit-image-build.yaml/dispatches` `{"ref":"epic/LD3"}` → 204. - **Run #1721** (api id 6468, event `workflow_dispatch`, `refs/heads/epic/LD3`) → **success**, `preflight` 4s + `build-and-push` 1m23s. Log: `no ref input: building 65e357ff9a…` → `Building …cockpit:65e357f` → `Image size OK.` → smoke `PASS` → `ref refs/heads/epic/LD3 is not live; …:latest untouched` → `Pushed: …cockpit@sha256:a248f426…`. **Digest to pin in the 3.1b deploy slice (PR #878):** ``` harbor.aaron.reynoza.org/lab-director/cockpit@sha256:a248f426d0a4bf685c7134401e8d2c851440a588f566ef724936c1094fa3bb4d ``` tag: `harbor.aaron.reynoza.org/lab-director/cockpit:65e357f` **crane verification**: `crane digest …:65e357f` → `sha256:a248f426…` (matches the log); `crane ls` → exactly one tag `65e357f`; `crane digest …:latest` → **404 not found** (`crane ls` has no `latest`) — nothing created or moved; image `created` `2026-10-02T07:13:15Z`, inside the run window.
Sign in to join this conversation.
No reviewers
No labels
operator-review
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/infra-core!580
No description provided.