Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 167 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,17 @@ name: Release
# stitches the digests into one manifest list per image with `docker buildx imagetools create`.
# The release FAILS if either arch fails: merge-manifests `needs` the whole build matrix, the
# merge script demands exactly two digests, and the inspect step asserts both platforms are in
# the pushed index. `home-latest` and the Release object only move after every merge succeeds.
# the pushed index. merge-manifests tags `home-<tag>` only (SPEC F179.1) -- nothing is called
# "latest" yet.
#
# Stack gate before promotion (SPEC F179, STORY-448, gh-#777):
# `home-latest` and the Release move only after `stack-gate` goes green (SPEC F179.2-F179.5).
# `stack-gate` boots the just-published tag fresh AND upgraded-from-the-previous-release, records
# the live stream, and proves the appliance actually holds up before anything downstream moves. A
# red gate leaves `home-<tag>` published and nothing else -- the promote and Release steps never
# run, and no pins PR follows. `gh run rerun --failed` re-enters at the gate (merge-manifests's
# images are already pushed, so nothing upstream needs to re-run). This workflow never deletes a
# tag.
#
# Pushing the tag is the ONLY manual act: this workflow never commits or pushes back to the repo.

Expand Down Expand Up @@ -171,8 +181,9 @@ jobs:
if-no-files-found: error
retention-days: 1

# One manifest list per image: stitch the two per-arch digests into
# `home-<tag>` + `home-latest`. Runs only when EVERY build-images leg succeeded.
# One manifest list per image: stitch the two per-arch digests into `home-<tag>` (SPEC F179.1
# -- `home-latest` moves later, in `promote`, only once `stack-gate` proves the tag boots).
# Runs only when EVERY build-images leg succeeded.
merge-manifests:
needs: build-images
runs-on: ubuntu-latest
Expand Down Expand Up @@ -241,7 +252,6 @@ jobs:
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
--annotation "index:org.opencontainers.image.version=$GW_TAG" \
-t "$REGISTRY_IMAGE:home-$GW_TAG" \
-t "$REGISTRY_IMAGE:home-latest" \
"${digests[@]}"
- name: Verify both architectures shipped
env:
Expand All @@ -253,22 +263,172 @@ jobs:
echo "$inspect_output" | grep -q 'linux/amd64'
echo "$inspect_output" | grep -q 'linux/arm64'

create-release:
# New job (SPEC F179.2, F183.2, STORY-448, gh-#777): proves the tag merge-manifests just
# published actually boots -- a fresh install AND an upgrade from the previous release -- before
# anything downstream promotes it. `promote` and `create-release` both need this (transitively).
stack-gate:
needs: merge-manifests
runs-on: ubuntu-latest
# gh-#777 STORY-448: --fresh + --upgrade + --capture run four legs end to end (setup, the
# upgrade-worktree checkout, a live boot, a recorded stream) -- 40 min budgets real headroom
# over the ~10-15 min observed on tools/gate/stack_gate.sh's own dev-box runs. SPEC F179.5: a
# red leg here leaves the tag published and nothing else -- `gh run rerun --failed` re-enters
# exactly here, since merge-manifests's images are already pushed.
timeout-minutes: 40
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # --upgrade's `gh release list` needs it
GW_TAG: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v7
with:
# --upgrade does `git worktree add` of the previous v* tag: needs the full tag
# history, not the default single-commit shallow clone.
fetch-depth: 0
- name: Install ffmpeg (stack_gate.sh's own capture leg needs the real binary)
uses: ./.github/actions/install-ffmpeg
- name: Fresh + upgrade + capture gate against the just-published tag
run: tools/gate/stack_gate.sh --tag "$GW_TAG" --fresh --upgrade --capture --report gate-report
# gate-report/ carries gate-report.md + gate-report.json (the pass/fail verdict per leg),
# capture.wav (the recorded stream), and, on any failed leg, the compose-fresh.log /
# compose-upgrade.log dumps stack_gate.sh attaches for debugging.
- name: Upload gate report
if: always()
uses: actions/upload-artifact@v7
with:
name: gate-report
path: gate-report/
retention-days: 7

# New job (SPEC F179.3): now that stack-gate has proven $GW_TAG boots fresh and upgraded, move
# the floating `home-latest` tag onto the images that just proved themselves. Five explicit
# retag+verify pairs, not a matrix: STORY-448's ItRetagsFiveImagesToHomeLatest /
# EachRetagSourcesTheTaggedImage pins count literal `-t …:home-latest` / source lines in this
# file's own YAML text, which a matrix's single parameterized step body would only satisfy once.
promote:
needs: stack-gate
runs-on: ubuntu-latest
# gh-#581 N1 ballpark: five retag+inspect pairs against ghcr.io, each well under a minute in
# merge-manifests's own timing; 10 min is large headroom below the 360-min default.
timeout-minutes: 10
permissions:
contents: read
packages: write # ghcr.io retag
env:
GW_TAG: ${{ github.ref_name }}
steps:
- uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v4
- name: Retag api -> home-latest
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave
run: |
docker buildx imagetools create \
-t "$REGISTRY_IMAGE:home-latest" \
"$REGISTRY_IMAGE:home-$GW_TAG"
- name: Verify api home-latest ships both architectures
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave
run: |
inspect_output="$(docker buildx imagetools inspect "$REGISTRY_IMAGE:home-latest")"
echo "$inspect_output"
echo "$inspect_output" | grep -q 'linux/amd64'
echo "$inspect_output" | grep -q 'linux/arm64'
- name: Retag icecast -> home-latest
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave-icecast
run: |
docker buildx imagetools create \
-t "$REGISTRY_IMAGE:home-latest" \
"$REGISTRY_IMAGE:home-$GW_TAG"
- name: Verify icecast home-latest ships both architectures
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave-icecast
run: |
inspect_output="$(docker buildx imagetools inspect "$REGISTRY_IMAGE:home-latest")"
echo "$inspect_output"
echo "$inspect_output" | grep -q 'linux/amd64'
echo "$inspect_output" | grep -q 'linux/arm64'
- name: Retag admin-ui -> home-latest
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave-admin-ui
run: |
docker buildx imagetools create \
-t "$REGISTRY_IMAGE:home-latest" \
"$REGISTRY_IMAGE:home-$GW_TAG"
- name: Verify admin-ui home-latest ships both architectures
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave-admin-ui
run: |
inspect_output="$(docker buildx imagetools inspect "$REGISTRY_IMAGE:home-latest")"
echo "$inspect_output"
echo "$inspect_output" | grep -q 'linux/amd64'
echo "$inspect_output" | grep -q 'linux/arm64'
- name: Retag engine -> home-latest
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave-engine
run: |
docker buildx imagetools create \
-t "$REGISTRY_IMAGE:home-latest" \
"$REGISTRY_IMAGE:home-$GW_TAG"
- name: Verify engine home-latest ships both architectures
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave-engine
run: |
inspect_output="$(docker buildx imagetools inspect "$REGISTRY_IMAGE:home-latest")"
echo "$inspect_output"
echo "$inspect_output" | grep -q 'linux/amd64'
echo "$inspect_output" | grep -q 'linux/arm64'
- name: Retag piper -> home-latest
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave-piper
run: |
docker buildx imagetools create \
-t "$REGISTRY_IMAGE:home-latest" \
"$REGISTRY_IMAGE:home-$GW_TAG"
- name: Verify piper home-latest ships both architectures
env:
REGISTRY_IMAGE: ghcr.io/genwave-org/genwave-piper
run: |
inspect_output="$(docker buildx imagetools inspect "$REGISTRY_IMAGE:home-latest")"
echo "$inspect_output"
echo "$inspect_output" | grep -q 'linux/amd64'
echo "$inspect_output" | grep -q 'linux/arm64'

create-release:
needs: promote
runs-on: ubuntu-latest
# gh-#581 N1: observed 7-40s (a single `gh release` call); 5 min is large headroom below
# the 360-min default.
timeout-minutes: 5
permissions:
contents: write # gh release create
steps:
- uses: actions/checkout@v7
# SPEC F179.4: pull the stack-gate's own report so the Release notes can carry its proof
# verbatim, instead of asserting a second time what stack-gate already asserted.
- name: Download the stack-gate report
uses: actions/download-artifact@v8
with:
name: gate-report
path: gate-report
- name: Build release notes (generated notes + the stack-gate's own proof)
run: |
{
echo "## ✅ What this release proved"
cat gate-report/gate-report.md
} > notes.md
# gh CLI over a third-party action -- it's preinstalled on the runner and needs only the
# default token. No repo write beyond the Release object itself (not a commit, not a push).
# Idempotent: a release created through the GitHub UI ("Draft a new release") creates the
# tag AND the Release together, so this workflow still fires on the tag push and must not
# 422 on the already-existing Release (observed on v2.0.0). Bare-tag pushes still get their
# Release created here.
# Release created here. `--generate-notes` composes with `--notes-file`: gh sends the file
# as the body with generate_release_notes on, and GitHub prepends a supplied body to its
# generated notes -- so the proof section above comes first, the generated notes after.
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -277,5 +437,5 @@ jobs:
if gh release view "$GW_TAG" --json name >/dev/null 2>&1; then
echo "Release $GW_TAG already exists (UI-created) -- skipping creation."
else
gh release create "$GW_TAG" --generate-notes --title "$GW_TAG"
gh release create "$GW_TAG" --generate-notes --notes-file notes.md --title "$GW_TAG"
fi
44 changes: 21 additions & 23 deletions tests/GenWave.Host.Tests/Specs/Story448_ReleaseWorkflowGate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ namespace GenWave.Host.Tests.Specs;

public static class FeatureReleasePromotionWaitsForTheStackGate
{
const string Pending = "pending: T502 — release.yml: stack-gate + promote, home-latest after the gate, notes from the report (STORY-448)";

static string Workflow => File.ReadAllText(Path.Combine(
RepoRootLocator.Find(AppContext.BaseDirectory), ".github", "workflows", "release.yml"));

Expand All @@ -34,86 +32,86 @@ static string Job(string name)

public sealed class ScenarioMergeManifestsNoLongerTagsHomeLatest
{
[Fact(Skip = Pending)]
[Fact]
public void NoHomeLatestInTheMergeJob() => Assert.DoesNotContain("home-latest", Job("merge-manifests"), StringComparison.Ordinal);
}

public sealed class ScenarioStackGateFollowsMergeManifests
{
readonly string job = Job("stack-gate");

[Fact(Skip = Pending)]
[Fact]
public void TheJobExists() => Assert.NotEqual("", job);

[Fact(Skip = Pending)]
[Fact]
public void ItNeedsMergeManifests() => Assert.Matches(@"needs:\s*merge-manifests", job);

[Fact(Skip = Pending)]
[Fact]
public void ItHasAFortyMinuteBudget() => Assert.Matches(@"timeout-minutes:\s*40", job);

[Fact(Skip = Pending)]
[Fact]
public void ItRunsTheFourLegs() =>
Assert.Matches(@"tools/gate/stack_gate\.sh .*--fresh .*--upgrade .*--capture .*--report", job);

[Fact(Skip = Pending)]
[Fact]
public void ItUploadsTheReportMarkdown() => Assert.Contains("gate-report.md", job, StringComparison.Ordinal);

[Fact(Skip = Pending)]
[Fact]
public void ItUploadsTheReportJson() => Assert.Contains("gate-report.json", job, StringComparison.Ordinal);

[Fact(Skip = Pending)]
[Fact]
public void ItUploadsTheCapture() => Assert.Contains("capture.wav", job, StringComparison.Ordinal);

[Fact(Skip = Pending)]
[Fact]
public void ItUploadsTheComposeLogs() => Assert.Contains("compose-", job, StringComparison.Ordinal);

[Fact(Skip = Pending)]
[Fact]
public void TheArtifactsKeepForSevenDays() => Assert.Matches(@"retention-days:\s*7", job);
}

public sealed class ScenarioPromoteRetagsAfterTheGate
{
readonly string job = Job("promote");

[Fact(Skip = Pending)]
[Fact]
public void ItNeedsStackGate() => Assert.Matches(@"needs:\s*stack-gate", job);

[Fact(Skip = Pending)]
[Fact]
public void ItUsesImagetoolsCreate() => Assert.Contains("imagetools create", job, StringComparison.Ordinal);

[Fact(Skip = Pending)]
[Fact]
public void ItRetagsFiveImagesToHomeLatest() => Assert.Equal(5, Regex.Matches(job, @"-t\s+""?\S*:home-latest").Count);

[Fact(Skip = Pending)]
[Fact]
public void EachRetagSourcesTheTaggedImage() => Assert.Equal(5, Regex.Matches(job, @"\S*:home-\$\{?GW_TAG\}?""?\s*$", RegexOptions.Multiline).Count);
}

public sealed class ScenarioCreateReleaseNeedsPromote
{
readonly string job = Job("create-release");

[Fact(Skip = Pending)]
[Fact]
public void ItNeedsPromote() => Assert.Matches(@"needs:\s*promote", job);

[Fact(Skip = Pending)]
[Fact]
public void ItGeneratesNotes() => Assert.Contains("--generate-notes", job, StringComparison.Ordinal);

[Fact(Skip = Pending)]
[Fact]
public void ItAppendsTheNotesFile() => Assert.Contains("--notes-file", job, StringComparison.Ordinal);

[Fact(Skip = Pending)]
[Fact]
public void TheNotesAreBuiltFromTheReport() => Assert.Contains("gate-report.md", job, StringComparison.Ordinal);

[Fact(Skip = Pending)]
[Fact]
public void TheNotesCarryTheProofHeading() => Assert.Contains("## ✅ What this release proved", job, StringComparison.Ordinal);
}

public sealed class ScenarioTheHeaderTellsTheNewTruth
{
[Fact(Skip = Pending)]
[Fact]
public void HomeLatestMovesAfterStackGate() => Assert.Matches(@"home-latest[^\n]*(after|behind)[^\n]*stack-gate", Header);

[Fact(Skip = Pending)]
[Fact]
public void TheOldPromiseIsGone() => Assert.DoesNotContain("only move after every merge succeeds", Header, StringComparison.Ordinal);
}
}
Loading
Loading