feat(release): build a dev channel so testing stops requiring a release
test-go / test (push) Successful in 1m11s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
test-go / integration (push) Successful in 5m55s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m11s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
test-go / integration (push) Successful in 5m55s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
There was no test channel at all. release.yml ran only on main and tags, so no :dev image existed and no APK was produced outside a release — the only way to get a build onto a phone was to ship one, which made `main` the staging area by default. A push to dev now builds a signed APK, bundles it, and publishes :dev. Signed with the SAME key as release builds, deliberately. A differently signed APK cannot install over the stable app, so anyone moving between channels would have to uninstall and lose their local data. Same key means both directions work. :dev is published ALONE, with no per-commit tag. A rolling channel is rolling by definition; a commit-addressable image for it would be a rollback target nobody ever pulls, kept forever. Recovery on dev is to fix forward, and that is a deliberate trade rather than an omission. The channel is derived from the REF, not the commit, which is why it is computed in the workflow and not in ci/version.sh. The same commit built on dev and on main reports the same version NAME and differs only in the channel field — that separation is the entire point of keeping the three values apart. What this repo deliberately does NOT get: a cross-repo dispatch to refresh the channel when its bundled APK is rebuilt. That mechanism exists elsewhere in the family because the app and server live in separate repos, and a channel that can only be refreshed by an unrelated commit is not a channel. Minstrel is a monorepo — one push builds the APK and the image in the same run from the same commit, so the channel cannot go stale against its own artifact. The requirement is met structurally; copying the mechanism would add a moving part to fix a problem that does not exist here. Two guards, for the two ways this wiring can fail quietly: A dev push must never move :latest. That would ship untested code to every stable operator on their next pull, with the build green and the image perfectly valid — just the wrong audience. Nothing else in the suite would notice. The two bundling paths must stay mutually exclusive. The rebundle step is now gated to main specifically, not to "not a tag": under the looser condition a dev push would run BOTH steps, staging its fresh APK and then overwriting it with the previous release's. The image still builds, the sidecar still parses, and the channel whose whole job is being current quietly serves stale art. Both falsified against the regressions they name before committing. Scribe task #3819, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
@@ -177,3 +177,66 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
||||
t.Error("release.yml derives a commit count again — that is not monotonic across branches")
|
||||
}
|
||||
}
|
||||
|
||||
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
|
||||
func devArm(t *testing.T, yaml string) string {
|
||||
t.Helper()
|
||||
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
||||
i := strings.Index(yaml, marker)
|
||||
if i < 0 {
|
||||
t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired")
|
||||
}
|
||||
rest := yaml[i+len(marker):]
|
||||
if j := strings.Index(rest, "\n else"); j >= 0 {
|
||||
return rest[:j]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// The worst regression this wiring can produce: a dev push that also moves
|
||||
// :latest would ship untested code to every stable operator, silently, on the
|
||||
// next pull. Nothing else in the suite would notice — the build stays green
|
||||
// and the image is valid, it is simply the wrong audience.
|
||||
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arm := devArm(t, string(body))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:dev") {
|
||||
t.Errorf("dev arm does not publish :dev\n%s", arm)
|
||||
}
|
||||
if strings.Contains(arm, ":latest") {
|
||||
t.Errorf("dev arm moves :latest — that ships dev code to every stable operator\n%s", arm)
|
||||
}
|
||||
// Rule 145: a rolling channel gets no commit-addressable tag.
|
||||
if strings.Contains(arm, "GITHUB_SHA") {
|
||||
t.Errorf("dev arm publishes a per-commit tag; a rolling channel should not\n%s", arm)
|
||||
}
|
||||
}
|
||||
|
||||
// The two bundling paths must stay mutually exclusive. If the rebundle step's
|
||||
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
|
||||
// its freshly-built APK, then overwrite it with the previous release's. The
|
||||
// image would still build and the sidecar would still parse — it would just
|
||||
// quietly serve stale art to the channel whose whole job is being current.
|
||||
func TestDevChannel_RebundlePathIsMainOnly(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]
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user