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
89 changes: 89 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
verify:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Repository conformance
run: bunx @hasna/contracts@0.8.2 repo-conformance .

- name: Vendored storage kit is current
run: bunx @hasna/contracts@0.8.2 vendor-kit --check .

# The publish gate itself: typecheck, tests, build, packed-artifact scan.
# Running the same script prepack runs is what keeps the wiring honest.
- name: Release verification
run: bun run verify:release

live-postgres:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: attachments
POSTGRES_PASSWORD: attachments
POSTGRES_DB: attachments_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U attachments -d attachments_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

# The command declared in hasna.contract.json (storage.pgTestGate).
- name: Live PostgreSQL gate
env:
HASNA_ATTACHMENTS_TEST_DATABASE_URL: postgresql://attachments:attachments@127.0.0.1:5432/attachments_test
run: |
HASNA_ATTACHMENTS_DATABASE_URL="${HASNA_ATTACHMENTS_TEST_DATABASE_URL:?point it at a throwaway Postgres; the live-PG gate must not pass without one}" \
HASNA_ATTACHMENTS_STORAGE_MODE=cloud \
ATTACHMENTS_REQUIRE_POSTGRES=1 \
bun test src/db

# A gate whose exit status is the same with and without a database is not
# a gate. Prove the difference on every run instead of trusting it.
- name: Gate must fail against a dead database
env:
HASNA_ATTACHMENTS_TEST_DATABASE_URL: postgres://nobody:nope@127.0.0.1:1/doesnotexist
run: |
set +e
HASNA_ATTACHMENTS_DATABASE_URL="$HASNA_ATTACHMENTS_TEST_DATABASE_URL" \
HASNA_ATTACHMENTS_STORAGE_MODE=cloud \
ATTACHMENTS_REQUIRE_POSTGRES=1 \
bun test src/db
status=$?
set -e
if [ "$status" -eq 0 ]; then
echo "::error::live-PostgreSQL gate reported a pass against an unreachable database" >&2
exit 1
fi
echo "gate failed as expected (exit $status)"
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 52 additions & 15 deletions hasna.contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,66 @@
"name": "attachments",
"class": "cli-with-store",
"contractVersion": "v1",
"kitVersion": "0.4.1",
"kitVersion": "0.8.2",
"description": "Open-source attachment transfer with local or private S3 storage, app-hosted share links, CLI, MCP, REST API (attachments-serve), and a generated SDK. Cloud service is PURE REMOTE (Amendment A1): reads/writes RDS Postgres and S3 directly.",
"bins": ["attachments", "attachments-mcp", "attachments-serve"],
"hosting": ["user-hosted"],
"deploymentModes": ["local", "self_hosted"],
"storage": {
"mode": "cloud",
"engines": ["sqlite", "postgres"],
"envPrefix": "HASNA_ATTACHMENTS_",
"aliasEnvPrefix": "ATTACHMENTS_",
"databaseUrlSecretRef": "hasna/oss/attachments/database-url",
"sqlitePath": "~/.hasna/attachments/db.sqlite"
"pgTestGate": {
"envVar": "HASNA_ATTACHMENTS_TEST_DATABASE_URL",
"command": "HASNA_ATTACHMENTS_DATABASE_URL=\"${HASNA_ATTACHMENTS_TEST_DATABASE_URL:?point it at a throwaway Postgres; the live-PG gate must not pass without one}\" HASNA_ATTACHMENTS_STORAGE_MODE=cloud ATTACHMENTS_REQUIRE_POSTGRES=1 bun test src/db"
}
},
"metadata": {
"surfaces": {
"cli": "attachments",
"mcp": "attachments-mcp",
"serve": "attachments-serve",
"sdk": "@hasna/attachments-sdk"
"serviceSurfaces": [
{
"name": "attachments-api",
"kind": "api",
"status": "supported",
"bin": "attachments-serve",
"authMode": "api-key",
"deploymentModes": ["self_hosted"],
"health": { "method": "GET", "path": "/health", "public": true },
"readiness": { "method": "GET", "path": "/ready", "public": true },
"version": { "method": "GET", "path": "/version", "public": true },
"apiBasePath": "/v1",
"openApiPath": "/openapi.json"
},
{
"name": "attachments-sdk",
"kind": "sdk",
"status": "supported",
"authMode": "none",
"deploymentModes": ["local", "self_hosted"],
"exportSubpath": ".",
"generatedFrom": "/openapi.json"
},
"serve": {
"port": 8080,
"probes": ["/health", "/ready", "/version"],
"openapi": "/openapi.json",
"apiVersion": "v1",
"auth": "api-key (@hasna/contracts)"
{
"name": "attachments-mcp",
"kind": "mcp",
"status": "supported",
"mcpBin": "attachments-mcp",
"authMode": "local-only",
"deploymentModes": ["local", "self_hosted"]
},
{
"name": "attachments-cli",
"kind": "cli",
"status": "supported",
"bin": "attachments",
"authMode": "local-only",
"deploymentModes": ["local", "self_hosted"]
}
],
"metadata": {
"release": {
"artifactScan": {
"script": "scan:artifact"
}
}
}
}
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
"typecheck": "bunx tsc --noEmit",
"test": "bash scripts/test.sh",
"test:coverage": "bash scripts/test.sh --coverage",
"verify:release": "bun run typecheck && bun run test && bun run build",
"verify:release": "bun run typecheck && bun run test && bun run build && bun run scan:artifact",
"prepack": "bun run verify:release",
"prepublishOnly": "bun run verify:release",
"scan:artifact": "bun scripts/scan-artifact.ts",
"dev": "bun run src/cli/index.ts",
"dashboard": "cd dashboard && bun run dev",
"dashboard:build": "cd dashboard && bun run build",
Expand All @@ -63,7 +65,7 @@
"@aws-sdk/client-s3": "^3.1007.0",
"@aws-sdk/lib-storage": "3.1007.0",
"@aws-sdk/s3-request-presigner": "^3.1007.0",
"@hasna/contracts": "^0.5.2",
"@hasna/contracts": "^0.8.2",
"@hasna/events": "^0.1.6",
"@modelcontextprotocol/sdk": "^1.27.1",
"@types/mime-types": "^3.0.1",
Expand Down
1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
minimumReleaseAgeExclude:
- '@hasna/contracts@0.4.1'
- '@hasna/contracts@0.5.2'
- '@hasna/contracts@0.8.2'
128 changes: 128 additions & 0 deletions scripts/scan-artifact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { CONTRACTS_KIT_VERSION, scannerCommand, scanPackedArtifact } from "./scan-artifact";

const repoRoot = join(import.meta.dir, "..");

function readText(relativePath: string): string {
return readFileSync(join(repoRoot, relativePath), "utf8");
}

function readJson(relativePath: string): Record<string, any> {
return JSON.parse(readText(relativePath));
}

/** Strip comments so a doc line naming an env API cannot mask a real read of it. */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, "");
}

/** Strip a leading range operator so "^0.8.2" and "0.8.2" compare equal. */
function rangeBaseVersion(range: string): string {
return range.replace(/^[\^~=v]+/, "");
}

/**
* Scripts reachable from `entry` through the pre/post lifecycle and `bun run` /
* `npm run` references. Mirrors the graph `@hasna/contracts repo-conformance`
* walks for its published_artifact_gate check, so the wiring is proven on every
* `bun run test` and not only when a human remembers to type the conformance
* CLI.
*/
function scriptsReachedBy(scripts: Record<string, string>, entry: string): Set<string> {
const reached = new Set<string>();
const queue: string[] = [entry];
const enqueue = (name: string | undefined) => {
if (name && name in scripts) queue.push(name);
};
while (queue.length > 0) {
const name = queue.shift() as string;
if (reached.has(name)) continue;
reached.add(name);
enqueue(`pre${name}`);
enqueue(`post${name}`);
const body = scripts[name];
if (!body) continue;
for (const match of body.matchAll(
/\b(?:bun|bunx|npm|pnpm|yarn)\s+(?:(?:--\S+|-\w)\s+)*(?:run\s+)?([a-zA-Z0-9_][\w:.-]*)/g,
)) {
enqueue(match[1]);
}
}
return reached;
}

describe("scan:artifact release gate", () => {
it("resolves the pinned scanner from source alone — the module reads no environment", () => {
// Setting one env name and asserting the argv is unchanged only proves the
// names we happened to think of; a bypass added under any other name stays
// green. Assert the invariant the module header claims instead: there is no
// environment input path at all, so there is nothing to override at publish
// time.
const source = stripComments(readText("scripts/scan-artifact.ts"));
expect(source).not.toMatch(/process\.env/);
expect(source).not.toMatch(/Bun\.env/);
expect(source).not.toMatch(/import\.meta\.env/);
expect(source).not.toMatch(/from\s+["']node:process["']/);

expect(scannerCommand("/tmp/pkg.tgz")).toEqual([
"bunx",
`@hasna/contracts@${CONTRACTS_KIT_VERSION}`,
"artifact-scan",
"/tmp/pkg.tgz",
]);
});

it("keeps prepack and prepublishOnly wired to the declared packed-artifact scan", () => {
// The deliverable here is the wiring, not the script. Drop `prepack`, or
// drop `scan:artifact` out of `verify:release`, and the scanner still runs
// clean in isolation while `bun publish` ships an unscanned artifact.
const scripts = readJson("package.json").scripts as Record<string, string>;
const declared = readJson("hasna.contract.json").metadata?.release?.artifactScan?.script;

expect(declared).toBe("scan:artifact");
expect(scripts[declared]).toBe("bun scripts/scan-artifact.ts");
expect(scripts["verify:release"]).toContain("bun run scan:artifact");

// npm/bun run `prepack` for `pm pack` and `prepublishOnly` for `publish`;
// a gate reachable from only one of them still has a publish-time hole.
for (const entry of ["prepack", "prepublishOnly"]) {
expect(scripts[entry]).toBeString();
expect([...scriptsReachedBy(scripts, entry)]).toContain(declared);
}
});

it("enforces the conformance and release gates in CI, not only on a reviewer's laptop", () => {
// `contracts repo-conformance` is what checks published_artifact_gate. With
// no workflow it runs when someone types it, which is not a gate.
const workflow = readText(".github/workflows/ci.yml");
expect(workflow).toContain(`bunx @hasna/contracts@${CONTRACTS_KIT_VERSION} repo-conformance .`);
expect(workflow).toContain(`bunx @hasna/contracts@${CONTRACTS_KIT_VERSION} vendor-kit --check .`);
expect(workflow).toContain("bun run verify:release");
// The live-PG gate declared in the contract has to actually execute.
expect(workflow).toContain("HASNA_ATTACHMENTS_TEST_DATABASE_URL");
expect(workflow).toContain("ATTACHMENTS_REQUIRE_POSTGRES");
});

it("keeps the kit version in lockstep with the contract, vendored kit and dependency", () => {
expect(readJson("hasna.contract.json").kitVersion).toBe(CONTRACTS_KIT_VERSION);
expect(readJson("src/generated/storage-kit/.storage-kit-manifest.json").kitVersion).toBe(
CONTRACTS_KIT_VERSION,
);
expect(rangeBaseVersion(readJson("package.json").dependencies["@hasna/contracts"])).toBe(
CONTRACTS_KIT_VERSION,
);
// The pinned version must be quarantine-excluded or a fresh install stalls.
expect(readText("pnpm-workspace.yaml")).toContain(`'@hasna/contracts@${CONTRACTS_KIT_VERSION}'`);
});

it("packs the artifact and passes the scan with the pinned kit", () => {
// Proves the pin actually resolves on the registry: an unpublished version
// makes bunx exit 1 here, exactly as it would in prepack.
const { command, output } = scanPackedArtifact();
expect(command[1]).toBe(`@hasna/contracts@${CONTRACTS_KIT_VERSION}`);
expect(output).toContain("pass artifact-scan");
expect(output).toContain("packed_artifact");
}, 300_000);
});
Loading
Loading