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
59 changes: 59 additions & 0 deletions .github/workflows/backfill-attestations.yml
Original file line number Diff line number Diff line change
@@ -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"
26 changes: 24 additions & 2 deletions .github/workflows/release-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ jobs:
timeout-minutes: 15
permissions:
contents: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -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'
Expand All @@ -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
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down
Binary file added tests/formats/adr-fuzz.test.ts
Binary file not shown.
224 changes: 224 additions & 0 deletions tests/formats/project-config-fuzz.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

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<string, unknown> = { 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<string, string> = {};
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({});
}
}
});
});
Loading