From fa688ff77e3a307c3b4cd194c719c602c5fe04cb Mon Sep 17 00:00:00 2001 From: cevheri Date: Fri, 7 Aug 2026 14:13:03 +0300 Subject: [PATCH] feat(azure): make the package workflow say where the artifact went and what to do with it The job ended with a green tick and no answer to the only question its operator has. The digests it pinned were buried in the build log, the artifact link existed nowhere in the output, and the Partner Center steps lived in a document that is no longer in the repo. So the run now explains itself. Three sections, each written by the step that knows its facts, as soon as it knows them - a run that fails validation still leaves a usable summary rather than none: 1. what was built: package version, app version, both image digests, the zip name and its SHA-256; 2. the arm-ttk result, with every failure and warning named (written BEFORE the gate, so a failing run explains itself in the summary and not only in the log); 3. where it went and what to do by hand. The builder now writes dist/azure/build-metadata.json beside the zip, because none of that provenance is recoverable from the zip itself: two JSON files with no trace of which app version or image digests they deploy. The checksum is read back off disk rather than computed from the bytes we meant to write, since its whole purpose is to describe the file a human uploads. One trap the summary spells out, verified by downloading the artifact from the last run: GitHub wraps artifacts in an archive of its own, so the download is a zip CONTAINING libredb-studio-azure-.zip. Partner Center needs the inner one - uploading GitHub's wrapper would fail certification on package structure. The two bash summary steps were rendered locally by extracting their `run:` blocks from the workflow file itself, not by retyping them, and executing them against a real build-metadata.json. --- .../workflows/azure-marketplace-package.yml | 67 +++++++++++++++++++ deploy/azure/README.md | 16 ++++- scripts/build-azure-package.mjs | 34 +++++++++- tests/unit/build-azure-package.test.ts | 21 ++++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/.github/workflows/azure-marketplace-package.yml b/.github/workflows/azure-marketplace-package.yml index 01b4cd0f..a849d814 100644 --- a/.github/workflows/azure-marketplace-package.yml +++ b/.github/workflows/azure-marketplace-package.yml @@ -34,6 +34,24 @@ jobs: PACKAGE_VERSION: ${{ inputs.packageVersion }} run: node scripts/build-azure-package.mjs --version "$APP_VERSION" --package-version "$PACKAGE_VERSION" + # Each step writes its own section as soon as its facts exist, so a run that + # fails validation still leaves a usable summary behind instead of nothing. + - name: Summarise what was built + run: | + jq -r ' + "## Azure Marketplace package \(.packageVersion)", + "", + "| | |", + "|---|---|", + "| Package version | `\(.packageVersion)` |", + "| App version | `\(.appVersion)` |", + "| App image | `\(.appImage)` |", + "| Caddy image | `\(.caddyImage)` |", + "| Package zip | `\(.zip)` |", + "| Package zip SHA-256 | `\(.zipSha256)` |", + "" + ' dist/azure/build-metadata.json >> "$GITHUB_STEP_SUMMARY" + # Pinned by release tag AND checksum: this toolkit is executed in the same # job that produces the zip submitted to Partner Center, so "latest" is # not an acceptable provenance. Bump tag + hash together when upgrading. @@ -77,6 +95,16 @@ jobs: $warned = @($results | Where-Object { $_.Warnings -and -not $_.Errors }) Write-Host "arm-ttk: $($results.Count) tests, $($failed.Count) failed, $($warned.Count) warned" foreach ($w in $warned) { Write-Host "::warning title=arm-ttk::$($w.Name): $($w.Warnings -join '; ')" } + + # Written before the gate, so a failing run explains itself in the summary + # instead of only in the log. + $summary = @("### arm-ttk marketplace suite", "", + "**$($results.Count) tests, $($failed.Count) failed, $($warned.Count) warned**", "") + foreach ($f in $failed) { $summary += "- FAILED - $($f.Name): $($f.Errors -join '; ')" } + foreach ($w in $warned) { $summary += "- warning - $($w.Name): $($w.Warnings -join '; ')" } + $summary += "" + $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($failed.Count -gt 0) { foreach ($f in $failed) { Write-Host "::error title=arm-ttk::$($f.Name): $($f.Errors -join '; ')" } Write-Error "arm-ttk reported $($failed.Count) failing test(s)" @@ -84,7 +112,46 @@ jobs: } - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + id: upload with: name: libredb-studio-azure-package path: dist/azure/*.zip if-no-files-found: error + + # Without this the job ends with a green tick and no answer to the only + # question its operator has: where the package went and what to do with it. + - name: Summarise where it went and what to do next + env: + ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + run: | + ZIP="$(jq -r .zip dist/azure/build-metadata.json)" + SHA="$(jq -r .zipSha256 dist/azure/build-metadata.json)" + VERSION="$(jq -r .packageVersion dist/azure/build-metadata.json)" + { + echo "### Download" + echo "" + echo "[**libredb-studio-azure-package**](${ARTIFACT_URL}) - the run's artifact, valid while this run is kept (90 days)." + echo "" + echo "> GitHub wraps artifacts in an archive of its own, so the download is a zip" + echo "> **containing** \`${ZIP}\`. Partner Center needs the INNER zip - the one whose" + echo "> root holds \`mainTemplate.json\` and \`createUiDefinition.json\` and nothing else." + echo "> Uploading GitHub's wrapper instead fails certification on package structure." + echo "" + echo "Verify the inner zip before uploading it:" + echo "" + echo '```bash' + echo "echo '${SHA} ${ZIP}' | sha256sum -c -" + echo '```' + echo "" + echo "### Then, by hand (no automation reaches past this point)" + echo "" + echo "1. Partner Center -> the offer -> the plan -> **Technical configuration**." + echo "2. Raise **Version** to \`${VERSION}\` and upload \`${ZIP}\`." + echo "3. **Review and publish**, wait for certification, then **Go live**." + echo " Customers keep the previously published package until Go live, so there is" + echo " no outage window - and Go live is deliberately a human decision." + echo "4. Bump \`deploy/azure/package-version.txt\` in the repo to match what you" + echo " published, otherwise the next build reuses a version Partner Center rejects." + echo "" + echo "Acceptance criteria for a real deployment: \`deploy/azure/README.md\`." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/deploy/azure/README.md b/deploy/azure/README.md index 40188b54..d8d0c866 100644 --- a/deploy/azure/README.md +++ b/deploy/azure/README.md @@ -29,9 +29,19 @@ files at the zip root, images pinned by manifest digest. The build fails if an warnings start at 540) — refresh values against `az provider show` when it warns. -In CI: run the **Azure Marketplace Package** workflow (workflow_dispatch), -which also validates the output with `Test-AzMarketplacePackage` (arm-ttk) and -uploads the zip as an artifact. +Beside it, `dist/azure/build-metadata.json` records what the zip pins — app +version, both image digests, and the zip's SHA-256. None of that is recoverable +from the zip itself, so it is what to keep if you ever need to say which images +a submitted package deployed. + +In CI: run the **Azure Marketplace Package** workflow (workflow_dispatch). It +validates the output with `Test-AzMarketplacePackage` (arm-ttk), uploads the zip +as an artifact, and writes a job summary carrying the digests, the checksum, the +artifact link and the Partner Center steps — so the run itself tells you what to +do next. + +> The artifact download is a zip **containing** the package zip, because GitHub +> wraps artifacts in an archive of its own. Partner Center needs the inner one. ## Validate before submitting diff --git a/scripts/build-azure-package.mjs b/scripts/build-azure-package.mjs index bc95d6ef..e7399ccf 100644 --- a/scripts/build-azure-package.mjs +++ b/scripts/build-azure-package.mjs @@ -28,6 +28,7 @@ */ import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -277,7 +278,38 @@ export async function buildPackage({ }, ); - return { zipPath, packageVersion: resolvedPackageVersion, version: resolvedVersion, appImage, caddyImage }; + // The checksum describes the file a human uploads to Partner Center by hand, so + // it is read back off disk rather than computed from the bytes we meant to write. + const zipSha256 = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex"); + + // Written beside the zip, not inside it: the package must stay exactly two files + // at the root. It carries what a reader of the finished zip cannot recover from + // it - which app version and which image digests it pins - so the CI job summary, + // and anyone auditing a submitted package later, can state that without guessing. + fs.writeFileSync( + path.join(root, "dist/azure/build-metadata.json"), + `${JSON.stringify( + { + packageVersion: resolvedPackageVersion, + appVersion: resolvedVersion, + appImage, + caddyImage, + zip: path.basename(zipPath), + zipSha256, + }, + null, + 2, + )}\n`, + ); + + return { + zipPath, + zipSha256, + packageVersion: resolvedPackageVersion, + version: resolvedVersion, + appImage, + caddyImage, + }; } async function main(argv) { diff --git a/tests/unit/build-azure-package.test.ts b/tests/unit/build-azure-package.test.ts index bfcd9253..013802a3 100644 --- a/tests/unit/build-azure-package.test.ts +++ b/tests/unit/build-azure-package.test.ts @@ -11,6 +11,7 @@ */ import { describe, expect, test } from "bun:test"; import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -277,6 +278,26 @@ describe("buildPackage (end to end against a fixture repo)", () => { expect(script).not.toContain("__APP_IMAGE__"); }); + test("writes a metadata file next to the zip, for the job summary and for provenance", async () => { + const root = makeFixtureRepo(); + const result = await buildPackage({ root, fetchImpl: registryFetch(), now: NOW, log: () => {} }); + + const metadata = JSON.parse(readFileSync(join(root, "dist/azure/build-metadata.json"), "utf8")); + expect(metadata).toEqual({ + packageVersion: "1.2.3", + appVersion: "0.9.66", + appImage: `ghcr.io/libredb/libredb-studio@${APP_DIGEST}`, + caddyImage: `docker.io/library/caddy@${CADDY_DIGEST}`, + zip: "libredb-studio-azure-1.2.3.zip", + zipSha256: result.zipSha256, + }); + // The hash has to describe the file a human actually uploads to Partner + // Center, so compare it against the bytes on disk rather than trusting the + // builder's own report of it. + expect(metadata.zipSha256).toBe(createHash("sha256").update(readFileSync(result.zipPath)).digest("hex")); + expect(metadata.zipSha256).toMatch(/^[0-9a-f]{64}$/); + }); + test("explicit --version wins over package.json for the app image tag", async () => { const root = makeFixtureRepo(); const seen: string[] = [];