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[] = [];