Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7529266f5b | ||
|
|
165c87047e | ||
|
|
05bc85f2b8 | ||
|
|
d1fe3758d5 | ||
|
|
664b129947 | ||
|
|
c9fa18ecb7 | ||
|
|
3077ed75fe | ||
|
|
97e84a79eb | ||
|
|
a29ceaaeab | ||
|
|
54b2720d76 | ||
|
|
fe142e3451 | ||
|
|
3bdfa94f39 | ||
|
|
22ea27efff | ||
|
|
09bd71629a | ||
|
|
c83670d216 | ||
|
|
1e9aed214b | ||
|
|
3638d1d822 | ||
|
|
18618bd135 | ||
|
|
d2985f3841 | ||
|
|
f70df9f827 | ||
|
|
439c8625d5 | ||
|
|
1d67c160b2 | ||
|
|
237380b122 | ||
|
|
4f077736b6 | ||
|
|
727f68950e | ||
|
|
aa9f534f3c | ||
|
|
011b4d9a9c | ||
|
|
d5aa081157 | ||
|
|
a99f855e98 | ||
|
|
7e4727fc49 | ||
|
|
1b7fa635d8 | ||
|
|
57d2299180 | ||
|
|
fa7ea41ccf | ||
|
|
324059b2bd | ||
|
|
1138d75a45 |
+102
-39
@@ -2,9 +2,9 @@ name: release
|
|||||||
|
|
||||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||||
#
|
#
|
||||||
# push to dev → :dev (freshly-built dev APK bundled)
|
# push to dev → :dev (freshly-built dev APK bundled)
|
||||||
# push to main → :latest + :<sha> (freshly-built stable APK bundled)
|
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled, and attached to the Release)
|
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
#
|
#
|
||||||
# That is the whole tag map.
|
# That is the whole tag map.
|
||||||
@@ -41,12 +41,6 @@ name: release
|
|||||||
# image in the same run from the same commit, and the channel cannot go
|
# image in the same run from the same commit, and the channel cannot go
|
||||||
# stale against its own artifact.
|
# stale against its own artifact.
|
||||||
#
|
#
|
||||||
# Every branch build carries the app built from its own commit, so a merge to
|
|
||||||
# main is all it takes to put a new app in front of stable phones: `:latest`
|
|
||||||
# serves it from /api/client/apk and the in-app update offers it. A release
|
|
||||||
# is an optional bookmark — a named point in history with the APK attached
|
|
||||||
# for download — never a step the app has to wait for.
|
|
||||||
#
|
|
||||||
# Release model: the tag IS the artifact's version name with a `v` in front.
|
# Release model: the tag IS the artifact's version name with a `v` in front.
|
||||||
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
|
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
|
||||||
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
|
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
|
||||||
@@ -62,14 +56,20 @@ name: release
|
|||||||
# :latest is updated by every main push AND every tag push, so it always
|
# :latest is updated by every main push AND every tag push, so it always
|
||||||
# reflects the newest blessed image.
|
# reflects the newest blessed image.
|
||||||
#
|
#
|
||||||
# APK pipeline: on every push — dev, main and tags alike — the
|
# APK pipeline: on tag pushes the android-release job builds + signs the
|
||||||
# android-release job builds + signs the Android APK and uploads it as a
|
# Android APK and uploads it as a workflow artifact. The image-release
|
||||||
# workflow artifact. The image-release job declares `needs: android-release`
|
# job declares `needs: android-release`, so the docker image cannot
|
||||||
# and requires its success, so the docker image cannot start building until
|
# start building until the APK is guaranteed-ready — no polling, no
|
||||||
# the APK is guaranteed-ready — no polling, no race, no silent-failure mode,
|
# race, no silent-failure mode. Attaching the APK to the gitea Release is
|
||||||
# and no image that moves a channel without an app inside it. Attaching the
|
# its own job (release-assets), behind the test gate below.
|
||||||
# APK to the gitea Release is its own job (release-assets), tags only, behind
|
#
|
||||||
# the test gate below.
|
# :latest always carries an APK. Because every main push also moves
|
||||||
|
# :latest (not just tags), a main build with no APK would silently strip
|
||||||
|
# the in-app update channel off :latest until the next release. So on
|
||||||
|
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||||
|
# AND the version sidecar published beside it — the recorded values, not
|
||||||
|
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
||||||
|
# keep bundling their own freshly-built APK.
|
||||||
#
|
#
|
||||||
# THE GATE. Every verifying lane lives in this file —
|
# THE GATE. Every verifying lane lives in this file —
|
||||||
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
||||||
@@ -400,14 +400,11 @@ jobs:
|
|||||||
# ------------------------------------------------------------ artifacts --
|
# ------------------------------------------------------------ artifacts --
|
||||||
|
|
||||||
android-release:
|
android-release:
|
||||||
name: Build signed APK
|
name: Build signed APK (releases and dev)
|
||||||
# Builds on every ref, so each channel's image carries the app from its
|
# Also builds on `dev`, which is what makes a test channel possible at
|
||||||
# own commit: `dev` for the test channel, `main` for stable phones, and a
|
# all: without it the only way to get a build onto a phone is to cut a
|
||||||
# tag for the copy attached to its Release.
|
# release, which quietly turns `main` into the staging area.
|
||||||
if: >-
|
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
||||||
startsWith(github.ref, 'refs/tags/v')
|
|
||||||
|| github.ref == 'refs/heads/dev'
|
|
||||||
|| github.ref == 'refs/heads/main'
|
|
||||||
runs-on: flutter-ci
|
runs-on: flutter-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
@@ -604,9 +601,9 @@ jobs:
|
|||||||
path: release-apk/
|
path: release-apk/
|
||||||
|
|
||||||
- name: Attach APK to gitea Release
|
- name: Attach APK to gitea Release
|
||||||
# Tag releases only. A branch build has no Release to hang assets on
|
# Tag releases only. A dev build has no Release to hang assets on and
|
||||||
# and does not need one — its image bundles the APK, and the server
|
# does not need one — the :dev image bundles the APK, and the server
|
||||||
# serves it from /api/client/apk.
|
# serves it from /api/client/apk like any other.
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
@@ -670,9 +667,10 @@ jobs:
|
|||||||
|
|
||||||
image-release:
|
image-release:
|
||||||
name: Build + push container image
|
name: Build + push container image
|
||||||
# Every lane must have SUCCEEDED, each named here, and so must the APK
|
# Every lane must have SUCCEEDED, each named here. Then the
|
||||||
# build: every ref bundles the APK built from its own commit, so an
|
# APK: tag and dev pushes build one and it must have succeeded; main
|
||||||
# image without one would move a channel with no app for phones to take.
|
# pushes skip android-release and bundle the latest release's APK
|
||||||
|
# instead, so for main alone a skipped android-release is expected.
|
||||||
needs: [go, integration, web, android, govulncheck, android-release]
|
needs: [go, integration, web, android, govulncheck, android-release]
|
||||||
if: >-
|
if: >-
|
||||||
${{
|
${{
|
||||||
@@ -682,7 +680,8 @@ jobs:
|
|||||||
&& needs.web.result == 'success'
|
&& needs.web.result == 'success'
|
||||||
&& needs.android.result == 'success'
|
&& needs.android.result == 'success'
|
||||||
&& needs.govulncheck.result == 'success'
|
&& needs.govulncheck.result == 'success'
|
||||||
&& needs.android-release.result == 'success' }}
|
&& (needs.android-release.result == 'success'
|
||||||
|
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
|
||||||
runs-on: go-ci
|
runs-on: go-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
@@ -748,10 +747,9 @@ jobs:
|
|||||||
# publish the moving channel tag and never a commit-addressable
|
# publish the moving channel tag and never a commit-addressable
|
||||||
# one.
|
# one.
|
||||||
#
|
#
|
||||||
# :latest moves here so the server hands out the same APK build
|
# :latest must move here rather than waiting for the next main
|
||||||
# that is attached to the Release: a phone that installed from the
|
# push, or the channel would carry the PREVIOUS release's APK
|
||||||
# Release page and one that updated in-app hold one build, with one
|
# indefinitely — a channel that cannot refresh itself.
|
||||||
# ordering key. Cut a release on main's tip, as :latest follows it.
|
|
||||||
CHANNEL=stable
|
CHANNEL=stable
|
||||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||||
@@ -784,8 +782,12 @@ jobs:
|
|||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
- name: Download signed APK artifact
|
- name: Download signed APK artifact
|
||||||
# android-release just produced this, on every ref.
|
# Tag and dev pushes — android-release just produced this. Only `main`
|
||||||
if: steps.guard.outputs.ready == 'true'
|
# takes the "Bundle latest release APK" path below, because it is the
|
||||||
|
# one ref that moves a channel without building an APK of its own.
|
||||||
|
if: >-
|
||||||
|
steps.guard.outputs.ready == 'true' &&
|
||||||
|
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||||
# Consuming half of the pair: stock download-artifact, which works here for
|
# Consuming half of the pair: stock download-artifact, which works here for
|
||||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
||||||
# out of the bundle). v8 runs on node24, which every CI-runner image
|
# out of the bundle). v8 runs on node24, which every CI-runner image
|
||||||
@@ -796,7 +798,9 @@ jobs:
|
|||||||
path: client/
|
path: client/
|
||||||
|
|
||||||
- name: Stage bundled APK + version sidecar
|
- name: Stage bundled APK + version sidecar
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: >-
|
||||||
|
steps.guard.outputs.ready == 'true' &&
|
||||||
|
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
# All three pulled from android-release's outputs so the sidecar the
|
# All three pulled from android-release's outputs so the sidecar the
|
||||||
@@ -817,6 +821,65 @@ jobs:
|
|||||||
cat client/minstrel.apk.version
|
cat client/minstrel.apk.version
|
||||||
ls -lh client/
|
ls -lh client/
|
||||||
|
|
||||||
|
- name: Bundle latest release APK (non-tag :latest builds)
|
||||||
|
# Main pushes don't build an APK, but they DO move :latest — so
|
||||||
|
# without this the in-app update channel would vanish from :latest
|
||||||
|
# until the next tag. Pull the most-recent release's signed APK and
|
||||||
|
# the sidecar published beside it, so what the server reports is what
|
||||||
|
# that build actually recorded rather than something re-derived here.
|
||||||
|
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||||
|
# version — if no release or APK asset can be resolved. That
|
||||||
|
# degradation only actually works because the greps below carry
|
||||||
|
# `|| true`; under the runner's default pipefail a non-matching grep
|
||||||
|
# kills the step instead of falling through to the empty-case branch.
|
||||||
|
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||||
|
if [ -z "${REL_JSON}" ]; then
|
||||||
|
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||||
|
fi
|
||||||
|
# `|| true` on every one of these, and it is load-bearing rather
|
||||||
|
# than defensive habit. The runner already invokes this shell as
|
||||||
|
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
|
||||||
|
# exits non-zero even though `head` succeeded — and the step dies at
|
||||||
|
# the assignment, before ever reaching the `if` written to handle the
|
||||||
|
# empty case. Every "degrades gracefully" branch below is unreachable
|
||||||
|
# without this.
|
||||||
|
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
|
||||||
|
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
|
||||||
|
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||||
|
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||||
|
fi
|
||||||
|
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||||
|
|
||||||
|
# Take the version the release RECORDED rather than recomputing it.
|
||||||
|
# Re-deriving it from the tagged commit would keep the formula in two
|
||||||
|
# files that must stay in step, and could only ever recover the name —
|
||||||
|
# the ordering key is build-time minutes and exists nowhere after that
|
||||||
|
# build ends.
|
||||||
|
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
|
||||||
|
if [ -n "${SIDECAR_URL}" ]; then
|
||||||
|
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
|
||||||
|
cat client/minstrel.apk.version
|
||||||
|
else
|
||||||
|
# Releases published before sidecars were attached. Their name is
|
||||||
|
# still recoverable from the tag, but their ordering key genuinely
|
||||||
|
# is not — so it is reported ABSENT rather than guessed. A wrong
|
||||||
|
# key is an install the platform refuses; an absent one just tells
|
||||||
|
# the client to fall back to comparing names, which is exactly
|
||||||
|
# what those builds already do.
|
||||||
|
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
|
||||||
|
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
|
||||||
|
fi
|
||||||
|
echo "::notice::bundled release APK from ${TAG}"
|
||||||
|
ls -lh client/
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
||||||
|
|||||||
@@ -272,41 +272,73 @@ func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A merge to main is enough to put a new app in front of stable phones: main
|
// The two bundling paths must stay mutually exclusive. If the rebundle step's
|
||||||
// builds the signed APK from its own commit and bakes it into :latest, as dev
|
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
|
||||||
// does for :dev. The regression to guard is main falling back to a tag-only
|
// its freshly-built APK, then overwrite it with the previous release's. The
|
||||||
// APK build, which leaves every merged app change waiting on a release while
|
// image would still build and the sidecar would still parse — it would just
|
||||||
// each build stays green.
|
// quietly serve stale art to the channel whose whole job is being current.
|
||||||
func TestEveryBranchShipsTheAppBuiltFromItsOwnCommit(t *testing.T) {
|
func TestDevChannel_RebundlePathIsMainOnly(t *testing.T) {
|
||||||
jobs := releaseJobs(t)
|
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||||
|
if err != nil {
|
||||||
build := strings.Join(strings.Fields(jobs["android-release"].If), " ")
|
t.Fatal(err)
|
||||||
for _, ref := range []string{"refs/heads/main", "refs/heads/dev", "refs/tags/v"} {
|
|
||||||
if !strings.Contains(build, ref) {
|
|
||||||
t.Errorf("android-release does not build on %s, so that ref's image carries no app of its own\n if: %s", ref, build)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
yaml := string(body)
|
||||||
|
|
||||||
image := strings.Join(strings.Fields(jobs["image-release"].If), " ")
|
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
||||||
if !strings.Contains(image, "needs.android-release.result == 'success'") || strings.Contains(image, "'skipped'") {
|
|
||||||
t.Errorf("image-release can publish without a freshly-built APK\n if: %s", image)
|
|
||||||
}
|
|
||||||
|
|
||||||
yaml := releaseYAML(t)
|
|
||||||
if strings.Contains(yaml, "releases/latest") {
|
|
||||||
t.Error("release.yml fetches the latest release; an image bundling a release's APK ships an app older than its own commit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both bundling steps run on every ref. Sliced from image-release on,
|
|
||||||
// because release-assets has a step of the same name.
|
|
||||||
i := strings.Index(yaml, "\n image-release:")
|
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
t.Fatal("no image-release job")
|
t.Fatal("no 'Bundle latest release APK' step")
|
||||||
}
|
}
|
||||||
for _, name := range []string{"Download signed APK artifact", "Stage bundled APK + version sidecar"} {
|
step := yaml[i:]
|
||||||
step := stepBody(t, yaml[i:], name)
|
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
||||||
if strings.Contains(step, "github.ref") {
|
step = step[:j]
|
||||||
t.Errorf("%q is gated by ref, so some ref's image ships without its APK\n%s", name, step)
|
}
|
||||||
|
if !strings.Contains(step, "github.ref == 'refs/heads/main'") {
|
||||||
|
t.Errorf("the rebundle step is not gated to main; a dev push would overwrite its own APK\n%s", step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The runner invokes `shell: bash` as `bash -e -o pipefail`. That makes any
|
||||||
|
// command substitution whose grep matches NOTHING fatal at the assignment —
|
||||||
|
// pipefail reports the grep's non-zero status even though `head` succeeded —
|
||||||
|
// so the step dies before reaching the `if` written to handle the empty case.
|
||||||
|
//
|
||||||
|
// This is not hypothetical. It took down the first `main` build after the
|
||||||
|
// version rework: the newest release predated sidecar assets, its
|
||||||
|
// `.apk.version` grep matched nothing, and the step aborted instead of
|
||||||
|
// falling through to the name-only branch that exists precisely for it. The
|
||||||
|
// same latent hazard sat on the other two assignments and had simply never
|
||||||
|
// fired, because their greps always matched.
|
||||||
|
//
|
||||||
|
// Pins the property that makes every "degrades gracefully" branch in that
|
||||||
|
// step reachable at all.
|
||||||
|
func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) {
|
||||||
|
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
yaml := string(body)
|
||||||
|
|
||||||
|
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("no 'Bundle latest release APK' step")
|
||||||
|
}
|
||||||
|
step := yaml[i:]
|
||||||
|
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
||||||
|
step = step[:j]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(step, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "#") || !strings.Contains(trimmed, "grep") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only assignments from a command substitution can abort the step.
|
||||||
|
if !strings.Contains(trimmed, `="$(`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(trimmed, "|| true") {
|
||||||
|
t.Errorf("this assignment dies under pipefail when its grep matches nothing, "+
|
||||||
|
"skipping the empty-case branch below it:\n %s", trimmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,7 +376,7 @@ func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
|
|||||||
arm, _, _ := imageTagArms(t, releaseYAML(t))
|
arm, _, _ := imageTagArms(t, releaseYAML(t))
|
||||||
|
|
||||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
if !strings.Contains(arm, "${IMAGE}:latest") {
|
||||||
t.Errorf("tag arm does not refresh :latest — the server would hand out a different APK build from the one attached to the Release\n%s", arm)
|
t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm)
|
||||||
}
|
}
|
||||||
if strings.Contains(arm, "GITHUB_SHA") {
|
if strings.Contains(arm, "GITHUB_SHA") {
|
||||||
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
|
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
|
||||||
|
|||||||
Reference in New Issue
Block a user