diff --git a/.github/workflows/backfill-attestations.yml b/.github/workflows/backfill-attestations.yml new file mode 100644 index 00000000..0f637948 --- /dev/null +++ b/.github/workflows/backfill-attestations.yml @@ -0,0 +1,59 @@ +name: Backfill Release Signatures + +on: + workflow_dispatch: + +permissions: {} + +jobs: + sign: + name: Sign existing release artifacts + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + id-token: write + steps: + - name: Install cosign + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + + - name: Sign release artifacts + env: + GH_TOKEN: ${{ github.token }} + run: | + WORKDIR=$(mktemp -d) + REPO="${{ github.repository }}" + signed=0 + skipped=0 + + for tag in $(gh release list --repo "$REPO" --limit 30 --json tagName --jq '.[].tagName'); do + echo "=== $tag ===" + existing=$(gh release view "$tag" --repo "$REPO" --json assets --jq '.assets[].name') + + for asset in $(echo "$existing" | grep -E '\.(tar\.gz|zip)$'); do + bundle="${asset}.sigstore.json" + + # Skip if already signed + if echo "$existing" | grep -qF "$bundle"; then + echo " $asset — already signed, skipping" + skipped=$((skipped + 1)) + continue + fi + + echo " Signing $asset..." + gh release download "$tag" --repo "$REPO" --pattern "$asset" --dir "$WORKDIR" --clobber + + cosign sign-blob --yes \ + --bundle "$WORKDIR/$bundle" \ + "$WORKDIR/$asset" + + gh release upload "$tag" --repo "$REPO" "$WORKDIR/$bundle" --clobber + + rm -f "$WORKDIR/$asset" "$WORKDIR/$bundle" + signed=$((signed + 1)) + done + done + + rm -rf "$WORKDIR" + echo "" + echo "Done: $signed artifacts signed, $skipped already signed" diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 41b84ddd..82e5d5ee 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -20,6 +20,8 @@ jobs: timeout-minutes: 15 permissions: contents: write + id-token: write + attestations: write strategy: fail-fast: false matrix: @@ -78,13 +80,32 @@ jobs: Remove-Item "archgate.exe" (Get-FileHash "${{ matrix.artifact }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.artifact }}.zip" | Out-File -Encoding ascii "${{ matrix.artifact }}.zip.sha256" + - name: Attest build provenance (Unix) + if: runner.os != 'Windows' + id: attest-unix + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4 + with: + subject-path: ${{ matrix.artifact }}.tar.gz + + - name: Attest build provenance (Windows) + if: runner.os == 'Windows' + id: attest-win + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4 + with: + subject-path: ${{ matrix.artifact }}.zip + - name: Upload release asset (Unix) if: runner.os != 'Windows' env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ github.event.release.tag_name || inputs.tag }}" - gh release upload "$TAG" "${{ matrix.artifact }}.tar.gz" "${{ matrix.artifact }}.tar.gz.sha256" --clobber + cp "${{ steps.attest-unix.outputs.bundle-path }}" "${{ matrix.artifact }}.tar.gz.sigstore.json" + gh release upload "$TAG" \ + "${{ matrix.artifact }}.tar.gz" \ + "${{ matrix.artifact }}.tar.gz.sha256" \ + "${{ matrix.artifact }}.tar.gz.sigstore.json" \ + --clobber - name: Upload release asset (Windows) if: runner.os == 'Windows' @@ -93,4 +114,5 @@ jobs: GH_TOKEN: ${{ github.token }} run: | $tag = "${{ github.event.release.tag_name || inputs.tag }}" - gh release upload $tag "${{ matrix.artifact }}.zip" "${{ matrix.artifact }}.zip.sha256" --clobber + Copy-Item "${{ steps.attest-win.outputs.bundle-path }}" "${{ matrix.artifact }}.zip.sigstore.json" + gh release upload $tag "${{ matrix.artifact }}.zip" "${{ matrix.artifact }}.zip.sha256" "${{ matrix.artifact }}.zip.sigstore.json" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7165062f..8fdca477 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: run: bun install --frozen-lockfile - name: Context check id: check - uses: TrigenSoftware/simple-release-action@v1 + uses: TrigenSoftware/simple-release-action@a796225481a62b5b46b37d4481ef7fb114a6d399 # v1 with: workflow: check github-token: ${{ steps.generate_token.outputs.token }} @@ -85,7 +85,7 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - name: Create or update pull request - uses: TrigenSoftware/simple-release-action@v1 + uses: TrigenSoftware/simple-release-action@a796225481a62b5b46b37d4481ef7fb114a6d399 # v1 with: workflow: pull-request github-token: ${{ github.token }} @@ -151,7 +151,7 @@ jobs: - name: Ensure main is up-to-date before release run: git pull --rebase origin main - name: Release - uses: TrigenSoftware/simple-release-action@v1 + uses: TrigenSoftware/simple-release-action@a796225481a62b5b46b37d4481ef7fb114a6d399 # v1 with: workflow: release github-token: ${{ steps.generate_token.outputs.token }} diff --git a/tests/formats/adr-fuzz.test.ts b/tests/formats/adr-fuzz.test.ts new file mode 100644 index 00000000..26a2d5b0 Binary files /dev/null and b/tests/formats/adr-fuzz.test.ts differ diff --git a/tests/formats/project-config-fuzz.test.ts b/tests/formats/project-config-fuzz.test.ts new file mode 100644 index 00000000..ba04339d --- /dev/null +++ b/tests/formats/project-config-fuzz.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from "bun:test"; + +import { + DomainNameSchema, + DomainPrefixSchema, + ProjectConfigSchema, +} from "../../src/formats/project-config"; + +// --------------------------------------------------------------------------- +// Generators — hand-rolled to avoid adding a devDependency (ARCH-006) +// --------------------------------------------------------------------------- + +const ASCII = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const SPECIAL = "!@#$%^&*()_+-=[]{}|;':\",./<>?\\\n\r\t "; + +function randomInt(max: number): number { + return Math.floor(Math.random() * max); +} + +function randomString(maxLen: number, charset = ASCII + SPECIAL): string { + const len = randomInt(maxLen); + return Array.from( + { length: len }, + () => charset[randomInt(charset.length)] + ).join(""); +} + +// --------------------------------------------------------------------------- +// DomainNameSchema fuzz +// --------------------------------------------------------------------------- + +const ITERATIONS = 500; + +describe("DomainNameSchema fuzz", () => { + test(`validates ${ITERATIONS} random strings without crashing`, () => { + for (let i = 0; i < ITERATIONS; i++) { + const input = randomString(50); + const result = DomainNameSchema.safeParse(input); + expect(result).toHaveProperty("success"); + } + }); + + test("boundary cases for length constraints (min 2, max 32)", () => { + const cases = [ + "", // too short + "a", // 1 char — too short + "ab", // 2 chars — minimum + "a".repeat(32), // 32 chars — maximum + "a".repeat(33), // 33 chars — over limit + "a".repeat(1000), + ]; + for (const c of cases) { + const result = DomainNameSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); + + test("regex boundary cases for kebab-case", () => { + const cases = [ + "backend", // valid + "ml-ops", // valid + "a1", // valid — letter then digit + "1abc", // invalid — starts with digit + "-abc", // invalid — starts with hyphen + "abc-", // valid — ends with hyphen (regex allows it) + "ABC", // invalid — uppercase + "aB", // invalid — mixed case + "ab cd", // invalid — space + "ab_cd", // invalid — underscore + "ab.cd", // invalid — dot + "ab--cd", // valid — double hyphen + "a-b-c-d-e-f", // valid — many hyphens + "ñ", // invalid — non-ASCII + "a\n", // invalid — newline + ]; + for (const c of cases) { + const result = DomainNameSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); + + test("handles non-string types", () => { + const cases = [ + null, + undefined, + 0, + false, + true, + [], + {}, + NaN, + Infinity, + Symbol("test"), + () => {}, + ]; + for (const c of cases) { + const result = DomainNameSchema.safeParse(c); + expect(result).toHaveProperty("success"); + expect(result.success).toBe(false); + } + }); +}); + +// --------------------------------------------------------------------------- +// DomainPrefixSchema fuzz +// --------------------------------------------------------------------------- + +describe("DomainPrefixSchema fuzz", () => { + test(`validates ${ITERATIONS} random strings without crashing`, () => { + for (let i = 0; i < ITERATIONS; i++) { + const input = randomString(20); + const result = DomainPrefixSchema.safeParse(input); + expect(result).toHaveProperty("success"); + } + }); + + test("boundary cases for length constraints (min 2, max 10)", () => { + const cases = [ + "", + "A", // 1 char — too short + "AB", // 2 chars — minimum + "A".repeat(10), // 10 chars — maximum + "A".repeat(11), // 11 chars — over limit + "A".repeat(1000), + ]; + for (const c of cases) { + const result = DomainPrefixSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); + + test("regex boundary cases for uppercase pattern", () => { + const cases = [ + "GEN", // valid + "MLOPS", // valid + "ML_OPS", // valid — underscore allowed + "A1", // valid — letter then digit + "1ABC", // invalid — starts with digit + "_ABC", // invalid — starts with underscore + "abc", // invalid — lowercase + "Ab", // invalid — mixed case + "AB CD", // invalid — space + "AB-CD", // invalid — hyphen + ]; + for (const c of cases) { + const result = DomainPrefixSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); +}); + +// --------------------------------------------------------------------------- +// ProjectConfigSchema fuzz +// --------------------------------------------------------------------------- + +describe("ProjectConfigSchema fuzz", () => { + test(`validates ${ITERATIONS} random config objects without crashing`, () => { + for (let i = 0; i < ITERATIONS; i++) { + const domainCount = randomInt(10); + const domains: Record = {}; + + for (let j = 0; j < domainCount; j++) { + const key = + Math.random() > 0.5 + ? randomString(15, "abcdefghijklmnopqrstuvwxyz0123456789-") + : randomString(15); + const value = + Math.random() > 0.5 + ? randomString(8, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") + : randomString(8); + domains[key] = value; + } + + const config: Record = { domains }; + + // Randomly add extra top-level keys + if (Math.random() > 0.7) { + config[randomString(10)] = randomString(20); + } + + const result = ProjectConfigSchema.safeParse(config); + expect(result).toHaveProperty("success"); + } + }); + + test("handles extreme domain counts", () => { + // Many domains + const domains: Record = {}; + for (let i = 0; i < 100; i++) { + domains[`domain${String.fromCodePoint(97 + (i % 26))}${i}`] = `D${i}`; + } + const result = ProjectConfigSchema.safeParse({ domains }); + expect(result).toHaveProperty("success"); + }); + + test("handles wrong shapes for domains field", () => { + const cases = [ + { domains: null }, + { domains: "not-an-object" }, + { domains: 42 }, + { domains: [] }, + { domains: true }, + { domains: { valid: 123 } }, // value is number, not string + { domains: { valid: null } }, // value is null + { domains: { valid: undefined } }, // value is undefined + { domains: { "": "" } }, // empty keys/values + ]; + for (const c of cases) { + const result = ProjectConfigSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); + + test("defaults correctly for missing or empty input", () => { + const cases = [undefined, {}, { domains: {} }]; + for (const c of cases) { + const result = ProjectConfigSchema.safeParse(c); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.domains).toEqual({}); + } + } + }); +});