mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Two release-pipeline defects found while pre-flighting 2.2.0, plus the image hardening that the second one exposed. The release job would have died immediately after pushing the v2.2.0 tag. draftRelease was turned on in #649 and never executed, and GitHub's /releases/tags/{tag} endpoint does not return draft releases, so all nine tag lookups in release.yml would have 404'd against the draft semantic-release had just created. Verified against this repo with a throwaway draft: the tag endpoint 404s while gh release view reads it and /releases/{id} returns the same REST shape. Every site now resolves the numeric id first, so existing jq expressions are untouched. The unfixed-vulnerability gate was measuring almost nothing. The blocking Trivy steps run ignore-unfixed, and trivy-unfixed-gate.mjs was meant to cover the remainder but defaults to CRITICAL with neither call site passing --severity. An unfixed HIGH was gated by nothing, and the arm64 image carried 79 of them while the summary read clean. Rather than document 79 findings, the image lost what it did not need: libde265 1.1.1 and libheif 1.23.1 are now built from source (the old libheif pin was itself affected by CVE-2026-3950, and Debian's libde265 1.0.11 was the decoder every .heic upload actually reached), and xvfb, wget and openssh-client are purged. 15 CVEs left the image outright and the HIGH gap fell to 65, each now carrying a rationale verified against the running container. curl gets its own section: bookworm-backports has a fixed 8.14.1, so claiming no fix was available would have been false. It is recorded as a declined fix. Verified on both architectures: gate exits 0, the source-built libde265 is the one libheif links, and HEIC, RAW, ImageMagick, Sharp AVIF and headless chromium all still work after the purge.
246 lines
13 KiB
TypeScript
246 lines
13 KiB
TypeScript
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: Contract assertions intentionally match GitHub expression and shell interpolation syntax.
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const root = process.cwd();
|
|
const releaseWorkflowPath = path.resolve(root, ".github/workflows/release.yml");
|
|
const manualAttestationPath = path.resolve(root, ".github/workflows/attest.yml");
|
|
const releaseConfigPath = path.resolve(root, ".releaserc.json");
|
|
const versionSyncPath = path.resolve(root, "scripts/sync-version.sh");
|
|
|
|
function readRequired(file: string): string {
|
|
expect(existsSync(file), `${path.relative(root, file)} is missing`).toBe(true);
|
|
return readFileSync(file, "utf8");
|
|
}
|
|
|
|
function job(workflow: string, name: string, nextName?: string): string {
|
|
const start = workflow.indexOf(` ${name}:\n`);
|
|
expect(start, `job ${name} is missing`).toBeGreaterThanOrEqual(0);
|
|
if (!nextName) return workflow.slice(start);
|
|
const end = workflow.indexOf(` ${nextName}:\n`, start + name.length + 3);
|
|
expect(end, `job ${nextName} is missing`).toBeGreaterThan(start);
|
|
return workflow.slice(start, end);
|
|
}
|
|
|
|
describe("release supply-chain closure", () => {
|
|
it("keeps the GitHub release private until every publication gate succeeds", () => {
|
|
const releaseConfig = JSON.parse(readRequired(releaseConfigPath));
|
|
const githubPlugin = releaseConfig.plugins.find(
|
|
(plugin: unknown) => Array.isArray(plugin) && plugin[0] === "@semantic-release/github",
|
|
);
|
|
expect(githubPlugin?.[1]?.draftRelease).toBe(true);
|
|
expect(githubPlugin?.[1]?.successCommentCondition).toBe(false);
|
|
expect(githubPlugin?.[1]?.releasedLabels).toBe(false);
|
|
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const publish = job(workflow, "publish-release");
|
|
expect(publish).toContain("needs: [release, aliases]");
|
|
expect(publish).toContain("Verify approved release is still a draft");
|
|
expect(publish).toContain("--draft=false");
|
|
});
|
|
|
|
it("commits custom release notes and the published changelog before tagging", () => {
|
|
const releaseConfig = JSON.parse(readRequired(releaseConfigPath));
|
|
const gitPlugin = releaseConfig.plugins.find(
|
|
(plugin: unknown) => Array.isArray(plugin) && plugin[0] === "@semantic-release/git",
|
|
);
|
|
expect(gitPlugin?.[1]?.assets).toContain(".release-notes/*.md");
|
|
expect(gitPlugin?.[1]?.assets).toContain("apps/docs/changelog.md");
|
|
|
|
const sync = readRequired(versionSyncPath);
|
|
expect(sync).toContain('manage-release-notes.mjs" archive "$VERSION"');
|
|
expect(sync).toContain('manage-release-notes.mjs" sync-docs "$VERSION"');
|
|
expect(sync).not.toContain('rm -f "$ROOT/.release-notes.md"');
|
|
});
|
|
|
|
it("recreates and verifies the exact expected draft on a tag-only retry", () => {
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const release = job(workflow, "release", "prebuilt");
|
|
const check = release.indexOf("- name: Check for new release");
|
|
const materialize = release.indexOf("- name: Materialize durable release notes");
|
|
const ensureDraft = release.indexOf("- name: Ensure exact GitHub draft");
|
|
|
|
expect(check).toBeGreaterThanOrEqual(0);
|
|
expect(materialize).toBeGreaterThan(check);
|
|
expect(ensureDraft).toBeGreaterThan(materialize);
|
|
expect(release).toContain('git checkout --detach "${release_commit}"');
|
|
expect(release).toContain("node scripts/manage-release-notes.mjs materialize");
|
|
expect(release).toContain('"${VERSION}" /tmp/release-notes.md');
|
|
// The release is created as a draft (draftRelease: true), and GitHub's
|
|
// /releases/tags/{tag} endpoint does not return drafts. Recovery therefore
|
|
// keys off the release id failing to resolve, not off a 404 from a tag
|
|
// lookup that can never succeed here.
|
|
expect(release).toContain('gh release view "v${VERSION}"');
|
|
expect(release).toContain("--json databaseId");
|
|
expect(release).toContain('if [[ ! "${release_id}" =~ ^[0-9]+$ ]]; then');
|
|
expect(release).toContain('gh release create "v${VERSION}"');
|
|
expect(release).toContain("--draft");
|
|
expect(release).toContain("--verify-tag");
|
|
expect(release).toContain("--notes-file /tmp/release-notes.md");
|
|
expect(release).toContain("GitHub draft body differs from committed release notes");
|
|
expect(release).not.toContain("- name: Update docs changelog");
|
|
expect(release).not.toContain("HEAD:main");
|
|
});
|
|
|
|
it("never resolves a drafted release through the tag endpoint", () => {
|
|
// Regression guard. GET /repos/{owner}/{repo}/releases/tags/{tag} returns 404
|
|
// for a draft, verified against this repo. Every tag lookup in this workflow
|
|
// ran against the draft semantic-release had just created, so the release job
|
|
// died immediately after pushing the tag. Resolve the numeric id with
|
|
// `gh release view` (which reads drafts) and call /releases/{id} instead.
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const tagLookups = workflow
|
|
.split("\n")
|
|
.filter((line) => !line.trimStart().startsWith("#"))
|
|
.filter((line) => /gh api\b[^\n]*releases\/tags\//.test(line));
|
|
expect(tagLookups).toEqual([]);
|
|
});
|
|
|
|
it("eliminates the arbitrary manual attestation workflow", () => {
|
|
expect(existsSync(manualAttestationPath)).toBe(false);
|
|
});
|
|
|
|
it("keeps unverified archives private and gates the manifest on native archive security", () => {
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const prebuilt = job(workflow, "prebuilt", "archive-security");
|
|
const archiveSecurity = job(workflow, "archive-security", "docker");
|
|
const manifest = job(workflow, "manifest", "image-provenance");
|
|
|
|
expect(prebuilt).toContain("name: prebuilt-${{ matrix.arch }}");
|
|
expect(prebuilt).toContain("path: |\n /tmp/${{ env.archive_name }}");
|
|
expect(prebuilt).not.toContain("gh release upload");
|
|
|
|
expect(archiveSecurity).toContain("needs: [release, prebuilt]");
|
|
expect(archiveSecurity).toContain("runner: ubuntu-latest");
|
|
expect(archiveSecurity).toContain("runner: ubuntu-24.04-arm");
|
|
expect(archiveSecurity).toContain("name: prebuilt-${{ matrix.arch }}");
|
|
expect(archiveSecurity).toContain("ref: ${{ needs.release.outputs.release_commit }}");
|
|
expect(archiveSecurity).toContain("Verify immutable release tag binding");
|
|
expect(archiveSecurity).toContain("sha256sum --check --strict");
|
|
expect(archiveSecurity).toContain('filter="data"');
|
|
expect(archiveSecurity).toContain("node_modules/.bin/tsx --version");
|
|
expect(archiveSecurity).toContain(
|
|
"cyclonedx-json=snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.cdx.json",
|
|
);
|
|
expect(archiveSecurity).toContain(
|
|
"spdx-json=snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.spdx.json",
|
|
);
|
|
expect(archiveSecurity).toContain("scan-type: fs");
|
|
expect(archiveSecurity).toContain("scan-ref: /tmp/prebuilt-root/snapotter");
|
|
expect(archiveSecurity).toContain('exit-code: "1"');
|
|
expect(archiveSecurity).toContain('gh release upload "v${VERSION}"');
|
|
expect(archiveSecurity).toContain("actions/attest-build-provenance@");
|
|
expect(archiveSecurity).toContain('subject-path: "/tmp/prebuilt/${{ env.archive_name }}"');
|
|
|
|
expect(manifest).toContain("archive-security");
|
|
});
|
|
|
|
it("attests only the exact manifest digest emitted by the immutable release job", () => {
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const manifest = job(workflow, "manifest", "image-provenance");
|
|
const provenance = job(workflow, "image-provenance", "release-subjects");
|
|
const aliases = job(workflow, "aliases");
|
|
|
|
expect(manifest).toContain("manifest_digest: ${{ steps.manifest_digest.outputs.digest }}");
|
|
expect(manifest).toContain("id: manifest_digest");
|
|
expect(manifest).toContain('echo "digest=${ghcr_digest}" >> "$GITHUB_OUTPUT"');
|
|
expect(manifest).toContain('[[ "${dockerhub_digest}" == "${ghcr_digest}" ]]');
|
|
|
|
expect(provenance).toContain("needs: [release, manifest]");
|
|
expect(provenance).toContain("ref: ${{ needs.release.outputs.release_commit }}");
|
|
expect(provenance).toContain("Verify immutable release tag binding");
|
|
expect(provenance).toContain("MANIFEST_DIGEST: ${{ needs.manifest.outputs.manifest_digest }}");
|
|
expect(provenance).toContain('[[ "${resolved_digest}" == "${MANIFEST_DIGEST}" ]]');
|
|
expect(provenance).toContain("subject-name: ghcr.io/snapotter-hq/snapotter");
|
|
expect(provenance).toContain("subject-name: docker.io/snapotter/snapotter");
|
|
expect(
|
|
provenance.match(/subject-digest: \$\{\{ needs\.manifest\.outputs\.manifest_digest \}\}/g),
|
|
).toHaveLength(2);
|
|
|
|
expect(aliases).toContain("needs: [release, manifest, image-provenance, release-subjects]");
|
|
});
|
|
|
|
it("cryptographically binds the generated release commit to every published subject", () => {
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const releaseSubjects = job(workflow, "release-subjects", "aliases");
|
|
const aliases = job(workflow, "aliases");
|
|
|
|
expect(releaseSubjects).toContain(
|
|
"needs: [release, archive-security, manifest, image-provenance]",
|
|
);
|
|
expect(releaseSubjects).toContain("ref: ${{ needs.release.outputs.release_commit }}");
|
|
expect(releaseSubjects).toContain(
|
|
"RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}",
|
|
);
|
|
expect(releaseSubjects).toContain("WORKFLOW_TRIGGER_COMMIT: ${{ github.sha }}");
|
|
expect(releaseSubjects).toContain('"releaseCommit": release_commit');
|
|
expect(releaseSubjects).toContain('"releaseTag": release_tag');
|
|
expect(releaseSubjects).toContain('"workflowTriggerCommit": workflow_trigger_commit');
|
|
expect(releaseSubjects).toContain('for arch in ("amd64", "arm64")');
|
|
expect(releaseSubjects).toContain('archive = f"snapotter-v{version}-linux-{arch}.tar.gz"');
|
|
expect(releaseSubjects).toContain("docker.io/snapotter/snapotter");
|
|
expect(releaseSubjects).toContain("ghcr.io/snapotter-hq/snapotter");
|
|
expect(releaseSubjects).toContain("actions/attest-build-provenance@");
|
|
expect(releaseSubjects).toContain('subject-path: "/tmp/${{ env.release_subjects_name }}"');
|
|
expect(releaseSubjects).toContain("Existing release-subject manifest differs");
|
|
expect(releaseSubjects).not.toContain("--clobber");
|
|
const build = releaseSubjects.indexOf("Build canonical release-subject manifest");
|
|
const revalidate = releaseSubjects.indexOf(
|
|
"Revalidate release tag immediately before attesting subjects",
|
|
);
|
|
const attest = releaseSubjects.indexOf("Attest release-commit subject binding");
|
|
expect(build).toBeGreaterThanOrEqual(0);
|
|
expect(revalidate).toBeGreaterThan(build);
|
|
expect(attest).toBeGreaterThan(revalidate);
|
|
expect(releaseSubjects.match(/git fetch --force --no-tags origin/g)).toHaveLength(2);
|
|
expect(aliases).toContain("needs: [release, manifest, image-provenance, release-subjects]");
|
|
});
|
|
|
|
it("never overwrites an existing immutable release asset", () => {
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const archiveSecurity = job(workflow, "archive-security", "docker");
|
|
const scan = job(workflow, "scan", "sbom");
|
|
const sbom = job(workflow, "sbom", "ai-bundles");
|
|
|
|
expect(archiveSecurity).toContain("verify_or_upload_asset() {");
|
|
expect(archiveSecurity).toContain("Existing immutable release asset differs");
|
|
expect(archiveSecurity).toContain("Expected exactly one immutable release asset after upload");
|
|
expect(scan).toContain("Existing immutable Trivy report differs");
|
|
expect(sbom).toContain("Existing immutable SBOM differs");
|
|
expect(workflow).not.toContain("--clobber");
|
|
});
|
|
|
|
it("uses disjoint archive and image compliance asset names", () => {
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const archiveSecurity = job(workflow, "archive-security", "docker");
|
|
const scan = job(workflow, "scan", "sbom");
|
|
const sbom = job(workflow, "sbom", "ai-bundles");
|
|
|
|
expect(archiveSecurity).toContain("snapotter-v${VERSION}-archive-linux-${ARCH}-trivy.json");
|
|
expect(scan).toContain("snapotter-v${VERSION}-image-${{ matrix.platform }}-trivy.json");
|
|
expect(sbom).toContain("snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.cdx.json");
|
|
});
|
|
|
|
it("does not create a fallible post-tag changelog commit", () => {
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const release = job(workflow, "release", "prebuilt");
|
|
|
|
expect(release).not.toContain("- name: Update docs changelog");
|
|
expect(release).not.toContain("git commit");
|
|
expect(release).not.toContain("HEAD:main");
|
|
});
|
|
|
|
it("pins every external action to an immutable commit", () => {
|
|
const workflow = readRequired(releaseWorkflowPath);
|
|
const externalUses = [...workflow.matchAll(/^\s*(?:-\s*)?uses:\s*([^./\s][^\s#]*)/gm)].map(
|
|
(match) => match[1],
|
|
);
|
|
|
|
expect(externalUses.length).toBeGreaterThan(0);
|
|
for (const action of externalUses) {
|
|
expect(action, `${action} must use an immutable commit`).toMatch(/@[0-9a-f]{40}$/);
|
|
}
|
|
});
|
|
});
|