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
67 changes: 67 additions & 0 deletions .github/workflows/azure-marketplace-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -77,14 +95,63 @@ 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)"
exit 1
}

- 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"
16 changes: 13 additions & 3 deletions deploy/azure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 33 additions & 1 deletion scripts/build-azure-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/build-azure-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[] = [];
Expand Down
Loading