From 24574ed413ecb7b6bcdbd77bd2a6149ae3137b91 Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Mon, 20 Apr 2026 19:57:51 -0700 Subject: [PATCH 1/4] feat(push): cf push --https for direct-to-S3 upload (2.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third push option alongside SFTP and `cf push --remote`. Use when both port 22 and GitHub are blocked by a corporate firewall but outbound HTTPS to AWS S3 is allowed. - New _push_https flow: picks wrapper GDS (+ user_defines.v when non-openframe), SHA-256s each file locally, asks the platform for pre-signed PUT URLs, PUTs each file directly to S3, then asks the platform to commit them. - Mutually exclusive with --remote. - No Git involvement — the platform synthesizes .cf/project.json server-side and the Lambda re-verifies sha256 byte-for-byte before staging on EFS. - README + portal docs updated to describe all three push modes. Made-with: Cursor --- README.md | 29 +++++ chipfoundry_cli/main.py | 251 +++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 3 files changed, 278 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a4c3350..fbfe1cc 100644 --- a/README.md +++ b/README.md @@ -577,6 +577,7 @@ cf push [OPTIONS] - `--sftp-username`: Override configured username (SFTP mode only) - `--sftp-key`: Override configured key path (SFTP mode only) - `--remote`: HTTPS-only upload via the ChipFoundry GitHub App (no SFTP). Use this when port 22 is blocked by your corporate firewall. +- `--https`: HTTPS-only direct upload to AWS S3 (no SFTP, no GitHub). Use this when both SFTP and GitHub are blocked by your corporate firewall. **SFTP mode (default):** 1. Verifies the project is linked to the platform and you are logged in @@ -610,6 +611,34 @@ What happens: > network, run `cf push --remote` instead. No VPN required — just outbound > HTTPS and a GitHub repo linked to the project. +**HTTPS direct mode — `cf push --https`:** + +Firewall-friendliest fallback: no SFTP, no GitHub, no Git at all. The CLI +uploads your push-critical files directly to an AWS S3 staging bucket over +HTTPS using short-lived pre-signed PUT URLs, then the platform stages the +objects onto your SFTP landing zone. Use this when your network blocks +both port 22 and GitHub. + +Preconditions: +- Project is linked to the platform (`cf init` or `cf link`) and you are logged in. +- Wrapper GDS exists locally under `gds/` (one of `user_project_wrapper.gds[.gz]`, + `user_analog_project_wrapper.gds[.gz]`, `openframe_project_wrapper.gds[.gz]`). +- For non-openframe projects, `verilog/rtl/user_defines.v` is also uploaded when present. +- Outbound HTTPS to `*.s3.us-east-2.amazonaws.com` is allowed. + +What happens: +1. `cf push --https` picks the wrapper GDS (and `user_defines.v` if applicable) and hashes each file with SHA-256 locally. +2. Platform returns one pre-signed PUT URL per file; the CLI PUTs each file directly to S3 over HTTPS. +3. Platform stages the objects onto your SFTP landing zone, re-verifying SHA-256 byte-for-byte and synthesizing `.cf/project.json` from the authoritative platform data. +4. Staged S3 objects are deleted on success; any leftovers are expired by the bucket lifecycle after 7 days. +5. `--submit` submits for review on success. + +> [!TIP] +> Try modes in this order: `cf push` → `cf push --remote` → `cf push --https`. +> The SFTP mode is fastest when unrestricted, `--remote` is the best HTTPS +> option when you already keep the project on GitHub, and `--https` is the +> "direct upload" escape hatch that works even with no Git. + **GDS File Handling:** - **Both compressed (`.gz`) and uncompressed (`.gds`) files are supported** - **No automatic compression** - files are uploaded as-is diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index 83db85d..cae53a5 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -1,6 +1,7 @@ import click import getpass -from typing import Optional, List +import hashlib +from typing import Optional, List, Tuple from chipfoundry_cli.check_refs import PRECHECK_CHECKS from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo from chipfoundry_cli.version_check import maybe_warn_outdated @@ -1572,6 +1573,233 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r console.print("[yellow]⚠ Submit failed — ensure the project has a name[/yellow]") +def _sha256_file(path: Path) -> str: + """SHA-256 of a file streamed in 4 KiB chunks. + + Must match the shuttle importer and the Lambda side so the server can + verify the upload byte-for-byte. + """ + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + h.update(chunk) + return h.hexdigest() + + +def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, str]]: + """Return [(rel_path, abs_path, size, kind)] for files the platform + accepts via --https. + + Picks exactly one wrapper GDS (analog/digital/openframe) and, for + non-openframe projects, ``verilog/rtl/user_defines.v`` if it exists. + Raises FileNotFoundError if no wrapper is present or + ValueError if multiple are. + """ + from chipfoundry_cli.utils import GDS_WRAPPER_BASES, GDS_WRAPPER_SUFFIXES, USER_DEFINES_REL + + hits: List[Tuple[str, str]] = [] + for kind, base in GDS_WRAPPER_BASES: + for suf in GDS_WRAPPER_SUFFIXES: + rel = base + suf + if (project_root / rel).is_file(): + hits.append((kind, rel)) + break + if not hits: + raise FileNotFoundError( + "No wrapper GDS found (expected one of gds/user_project_wrapper.gds[.gz], " + "gds/user_analog_project_wrapper.gds[.gz], " + "gds/openframe_project_wrapper.gds[.gz])." + ) + if len(hits) > 1: + paths = ", ".join(h[1] for h in hits) + raise ValueError( + f"Multiple wrapper GDS layouts present ({paths}). Keep only one." + ) + + kind, wrapper_rel = hits[0] + abs_wrapper = project_root / wrapper_rel + results: List[Tuple[str, Path, int, str]] = [ + (wrapper_rel, abs_wrapper, abs_wrapper.stat().st_size, "wrapper") + ] + + if kind != "openframe": + ud = project_root / USER_DEFINES_REL + if ud.is_file(): + results.append((USER_DEFINES_REL, ud, ud.stat().st_size, "aux")) + + return results + + +def _push_https(project_root: Optional[str], project_name: Optional[str], dry_run: bool, submit: bool) -> None: + """Push project files to the platform by uploading directly to S3 over HTTPS. + + Use case: customers whose network blocks BOTH SFTP (port 22) and + GitHub, so they cannot use `cf push` or `cf push --remote`. They can + still reach AWS S3 over HTTPS, which is what the backend hands them + via pre-signed PUT URLs. + + No Git involvement at all — the CLI hashes the local files, the + backend returns pre-signed URLs, the CLI PUTs directly to S3, and + the platform stages the objects onto EFS with the same synthesized + .cf/project.json the --remote flow produces. + """ + cwd_root, cwd_project_name = get_project_json_from_cwd() + if not project_root and cwd_root: + project_root = cwd_root + if not project_name and cwd_project_name: + project_name = cwd_project_name + if not project_root: + console.print( + "[red]No project root specified and no .cf/project.json found in current directory.[/red]" + ) + console.print("Provide --project-root or run from a linked project.") + raise click.Abort() + project_root = str(Path(project_root).resolve()) + + platform_id = _load_project_platform_id(project_root) + if not platform_id: + console.print("[red]Project is not linked to the platform.[/red]") + console.print("Run [bold]cf link[/bold] to connect this project, or [bold]cf init[/bold] to create a new one.") + raise click.Abort() + + config = load_user_config() + if not config.get("api_key"): + console.print("[red]Not logged in.[/red] Run [bold]cf login[/bold] before using --https.") + raise click.Abort() + + try: + candidates = _collect_push_candidates(Path(project_root)) + except FileNotFoundError as e: + console.print(f"[red]HTTPS push not ready:[/red] {e}") + raise click.Abort() + except ValueError as e: + console.print(f"[red]HTTPS push not ready:[/red] {e}") + raise click.Abort() + + final_project_name = project_name or Path(project_root).name + + total_bytes = sum(c[2] for c in candidates) + mb = total_bytes / (1024 * 1024) + console.print( + f"[green]✓ Ready to upload[/green] [cyan]{len(candidates)}[/cyan] file(s) " + f"([cyan]{mb:.1f} MiB[/cyan] total) to the platform over HTTPS." + ) + + if dry_run: + console.print("\n[bold]HTTPS push preview:[/bold]") + console.print(f" Platform project: {platform_id}") + console.print(f" Project name: {final_project_name}") + for rel, abs_path, size, _ in candidates: + console.print(f" • {rel} ({size / (1024 * 1024):.1f} MiB)") + console.print(" (no files uploaded — dry run)") + return + + console.print("[dim]Hashing files locally…[/dim]") + hashed: List[dict] = [] + for rel, abs_path, size, _ in candidates: + digest = _sha256_file(abs_path) + hashed.append({"rel_path": rel, "size": size, "sha256": digest}) + console.print(f" [dim]sha256[/dim] {digest[:16]}… {rel}") + + console.print("Requesting upload slots from the platform…") + try: + init_resp = _api_post( + f"/projects/{platform_id}/https-push/init", + {"project_name": final_project_name, "files": hashed}, + timeout=60.0, + ) + except SystemExit: + raise click.Abort() + + upload_id = init_resp.get("upload_id") or "" + put_targets = {f["rel_path"]: f["put_url"] for f in (init_resp.get("files") or [])} + if not upload_id or len(put_targets) != len(candidates): + console.print("[red]✗ Platform did not return upload slots for every file.[/red]") + raise click.Abort() + + console.print( + f"[dim]Upload id [bold]{upload_id[:8]}[/bold] — uploading to " + f"{init_resp.get('bucket')} (HTTPS, {init_resp.get('expires_in', 3600)}s TTL)…[/dim]" + ) + + # Per-file single PUT. We reuse one httpx client with a generous + # timeout; the signed URL carries auth so no headers besides + # x-amz-server-side-encryption are required. + import httpx + + put_timeout = httpx.Timeout(connect=10.0, read=1800.0, write=1800.0, pool=30.0) + with httpx.Client(timeout=put_timeout) as put_client: + for rel, abs_path, size, _ in candidates: + url = put_targets[rel] + console.print( + f" [cyan]↑[/cyan] {rel} [dim]({size / (1024 * 1024):.1f} MiB)…[/dim]" + ) + try: + with open(abs_path, "rb") as fh: + resp = put_client.put( + url, + content=fh, + headers={ + "Content-Type": "application/octet-stream", + "x-amz-server-side-encryption": "AES256", + }, + ) + if resp.status_code >= 300: + body = resp.text[:300] + console.print( + f"[red]✗ Upload of {rel} failed: HTTP {resp.status_code} — {body}[/red]" + ) + raise click.Abort() + except click.Abort: + raise + except Exception as e: + console.print(f"[red]✗ Upload of {rel} failed: {type(e).__name__}: {e}[/red]") + raise click.Abort() + + console.print("[green]✓ All files uploaded. Asking platform to stage them on EFS…[/green]") + try: + complete_resp = _api_post( + f"/projects/{platform_id}/https-push/complete", + {"upload_id": upload_id, "project_name": final_project_name}, + timeout=600.0, + ) + except SystemExit: + raise click.Abort() + + landed = complete_resp.get("landed") or [] + if landed: + console.print("[green]✓ Files staged on the platform:[/green]") + for rel in landed: + console.print(f" • {rel}") + else: + console.print("[yellow]⚠ Platform accepted the request but did not report any landed files.[/yellow]") + + try: + with open(Path(project_root) / ".cf" / "project.json", "r") as f: + pj = json.load(f) + _api_put( + f"/projects/{platform_id}", + {"cli_project_json": _slim_project_json(pj), "cli_sync_source": "push"}, + timeout=60.0, + ) + console.print("[green]✓ Platform project synced[/green]") + except FileNotFoundError: + # .cf/project.json is synthesized server-side now; we still PUT the + # local copy if present for UX parity, but it's not required. + pass + except SystemExit: + console.print("[yellow]⚠ HTTPS push succeeded but platform sync failed[/yellow]") + except Exception: + console.print("[yellow]⚠ Could not read project.json for platform sync[/yellow]") + + if submit: + try: + _api_post(f"/projects/{platform_id}/submit", {}) + console.print("[green]✓ Project submitted for review[/green]") + except SystemExit: + console.print("[yellow]⚠ Submit failed — ensure the project has a name[/yellow]") + + @main.command('push') @click.option('--project-root', required=False, type=click.Path(exists=True, file_okay=False), help='Path to the local ChipFoundry project directory (defaults to current directory if .cf/project.json exists).') @click.option('--sftp-host', default=DEFAULT_SFTP_HOST, show_default=True, help='SFTP server hostname.') @@ -1584,8 +1812,25 @@ def _push_remote(project_root: Optional[str], project_name: Optional[str], dry_r @click.option('--dry-run', is_flag=True, help='Preview actions without uploading files.') @click.option('--submit', is_flag=True, help='Submit the project for review after upload.') @click.option('--remote', is_flag=True, help='Use the ChipFoundry GitHub App (HTTPS only) instead of SFTP. Useful when port 22 is blocked by a corporate firewall.') -def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit, remote): - """Upload your project files to the ChipFoundry SFTP server (or via GitHub with --remote).""" +@click.option('--https', 'https_mode', is_flag=True, help='Upload files directly over HTTPS (via S3 pre-signed URLs). Useful when both SFTP and GitHub are blocked.') +def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_name, project_type, force_overwrite, dry_run, submit, remote, https_mode): + """Upload your project files to the ChipFoundry SFTP server. + + Defaults to SFTP. Use --remote to push via the ChipFoundry GitHub App + (HTTPS), or --https to upload directly to AWS S3 (also HTTPS) without + needing Git. The two HTTPS modes are mutually exclusive. + """ + if remote and https_mode: + console.print("[red]--remote and --https are mutually exclusive.[/red]") + raise click.Abort() + if https_mode: + _push_https( + project_root=project_root, + project_name=project_name, + dry_run=dry_run, + submit=submit, + ) + return if remote: _push_remote( project_root=project_root, diff --git a/pyproject.toml b/pyproject.toml index f3e4230..2aa7c48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.4.1" +version = "2.5.0" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" From 48e6344047ac9ab4bbbb577e399eaed6888a8c94 Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Mon, 20 Apr 2026 20:03:51 -0700 Subject: [PATCH 2/4] fix(api): surface backend error detail instead of bare HTTP status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _api_get/_api_post/_api_put were printing the raw `Client error '409 Conflict' for url ...` message from httpx when the backend returned a 4xx/5xx. That dropped the useful bit — FastAPI's `{"detail": "..."}` payload — so users saw no actionable reason. Now extracts `detail` (string or validation-list form) and prints `HTTP 409: `. Applies to every CLI command that goes through the shared _api_* helpers (push --remote, push --https, link, etc.). Made-with: Cursor --- chipfoundry_cli/main.py | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index cae53a5..ab87c90 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -4551,8 +4551,39 @@ def _api_client(): return client, api_url +def _format_api_error(resp) -> str: + """Build a user-friendly error message from a platform error response. + + FastAPI returns errors as `{"detail": "..."}` (or a list of validation + errors). Surfacing that instead of the bare `Client error '409 Conflict'` + lets users act on the real reason without tailing backend logs. + """ + status = resp.status_code + try: + body = resp.json() + except Exception: + snippet = (resp.text or "").strip() + if snippet: + return f"HTTP {status}: {snippet[:300]}" + return f"HTTP {status}" + detail = body.get("detail") if isinstance(body, dict) else None + if isinstance(detail, str) and detail: + return f"HTTP {status}: {detail}" + if isinstance(detail, list) and detail: + parts = [] + for item in detail: + if isinstance(item, dict): + loc = ".".join(str(p) for p in (item.get("loc") or [])[-2:]) + msg = item.get("msg") or "" + parts.append(f"{loc}: {msg}" if loc else msg) + if parts: + return f"HTTP {status}: {'; '.join(parts)}" + return f"HTTP {status}: {body}" + + def _api_get(path: str): """Authenticated GET to the platform API. Returns parsed JSON or raises SystemExit.""" + import httpx as _httpx client, _ = _api_client() try: resp = client.get(path) @@ -4563,6 +4594,9 @@ def _api_get(path: str): return resp.json() except SystemExit: raise + except _httpx.HTTPStatusError as e: + console.print(f"[red]✗ API request failed: {_format_api_error(e.response)}[/red]") + raise SystemExit(1) except Exception as e: console.print(f"[red]✗ API request failed: {e}[/red]") raise SystemExit(1) @@ -4577,6 +4611,7 @@ def _api_post(path: str, json_data: dict, timeout: Optional[float] = None): Use a large value for long-running endpoints such as remote-push, which waits for the platform to fetch files from GitHub and stage them on EFS. """ + import httpx as _httpx client, _ = _api_client() try: kwargs = {"json": json_data} @@ -4590,6 +4625,9 @@ def _api_post(path: str, json_data: dict, timeout: Optional[float] = None): return resp.json() except SystemExit: raise + except _httpx.HTTPStatusError as e: + console.print(f"[red]✗ API request failed: {_format_api_error(e.response)}[/red]") + raise SystemExit(1) except Exception as e: console.print(f"[red]✗ API request failed: {e}[/red]") raise SystemExit(1) @@ -4602,6 +4640,7 @@ def _api_put(path: str, json_data: dict, timeout: Optional[float] = None): `timeout` (seconds) overrides the client default for this request only. """ + import httpx as _httpx client, _ = _api_client() try: kwargs = {"json": json_data} @@ -4615,6 +4654,9 @@ def _api_put(path: str, json_data: dict, timeout: Optional[float] = None): return resp.json() except SystemExit: raise + except _httpx.HTTPStatusError as e: + console.print(f"[red]✗ API request failed: {_format_api_error(e.response)}[/red]") + raise SystemExit(1) except Exception as e: console.print(f"[red]✗ API request failed: {e}[/red]") raise SystemExit(1) From 5d66cfa174231f75f8fe1c42ca059b4612ef13c8 Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Mon, 20 Apr 2026 20:50:36 -0700 Subject: [PATCH 3/4] chore: re-level to 2.4.3 for the --https push feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cf push --https is a transport variant of the existing push command, not a standalone new capability — same manifest/sync flow as --remote, which also shipped as a patch under 2.3.x. Rolling back the earlier jump to 2.5.0 so the --https work ships as 2.4.3 alongside the error-surfacing fix on the same feature branch. Made-with: Cursor --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2aa7c48..40b0906 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.5.0" +version = "2.4.3" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" From 555672b2b9905a4d573e42cd4d5f148d72963e4b Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Mon, 20 Apr 2026 21:14:36 -0700 Subject: [PATCH 4/4] feat(push): progress bar for cf push --https uploads (2.4.4) Matches the rich progress UX of the SFTP push path (utils.upload_with_progress): percent, bytes, transfer speed and elapsed time per file. We stream the body with a generator so httpx can drive bar updates on every chunk, and set Content-Length explicitly so S3 doesn't fall back to chunked encoding (pre-signed PUTs don't allow it). Made-with: Cursor --- chipfoundry_cli/main.py | 62 ++++++++++++++++++++++++++++++----------- pyproject.toml | 2 +- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index ab87c90..d410c8c 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -1725,36 +1725,66 @@ def _push_https(project_root: Optional[str], project_name: Optional[str], dry_ru # Per-file single PUT. We reuse one httpx client with a generous # timeout; the signed URL carries auth so no headers besides # x-amz-server-side-encryption are required. + # + # We stream the body with a generator instead of passing the file + # directly so we can drive a rich progress bar (matches the UX of + # the SFTP push path in utils.upload_with_progress). Content-Length + # is set explicitly so S3 doesn't fall back to chunked encoding, + # which pre-signed PUTs don't allow. import httpx + from rich.progress import DownloadColumn, TransferSpeedColumn put_timeout = httpx.Timeout(connect=10.0, read=1800.0, write=1800.0, pool=30.0) + chunk_size = 1024 * 1024 # 1 MiB — big enough to keep overhead low, small enough for smooth bar updates with httpx.Client(timeout=put_timeout) as put_client: for rel, abs_path, size, _ in candidates: url = put_targets[rel] - console.print( - f" [cyan]↑[/cyan] {rel} [dim]({size / (1024 * 1024):.1f} MiB)…[/dim]" - ) - try: - with open(abs_path, "rb") as fh: + with Progress( + TextColumn(" [cyan]↑[/cyan] [progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + DownloadColumn(), + TransferSpeedColumn(), + TimeElapsedColumn(), + console=console, + transient=False, + ) as progress: + task = progress.add_task(rel, total=max(size, 1)) + if size == 0: + # httpx won't call our generator for an empty body; + # advance the bar manually so the user sees it complete. + progress.update(task, completed=1) + + def _body_iter(path=abs_path, tid=task, prog=progress): + with open(path, "rb") as fh: + while True: + buf = fh.read(chunk_size) + if not buf: + break + prog.update(tid, advance=len(buf)) + yield buf + + try: resp = put_client.put( url, - content=fh, + content=_body_iter() if size > 0 else b"", headers={ "Content-Type": "application/octet-stream", + "Content-Length": str(size), "x-amz-server-side-encryption": "AES256", }, ) - if resp.status_code >= 300: - body = resp.text[:300] - console.print( - f"[red]✗ Upload of {rel} failed: HTTP {resp.status_code} — {body}[/red]" - ) + if resp.status_code >= 300: + body = resp.text[:300] + console.print( + f"[red]✗ Upload of {rel} failed: HTTP {resp.status_code} — {body}[/red]" + ) + raise click.Abort() + except click.Abort: + raise + except Exception as e: + console.print(f"[red]✗ Upload of {rel} failed: {type(e).__name__}: {e}[/red]") raise click.Abort() - except click.Abort: - raise - except Exception as e: - console.print(f"[red]✗ Upload of {rel} failed: {type(e).__name__}: {e}[/red]") - raise click.Abort() console.print("[green]✓ All files uploaded. Asking platform to stage them on EFS…[/green]") try: diff --git a/pyproject.toml b/pyproject.toml index 40b0906..d6b3556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.4.3" +version = "2.4.4" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md"