chipfoundry-cli 2.5.1__tar.gz → 2.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: chipfoundry-cli
3
- Version: 2.5.1
3
+ Version: 2.5.4
4
4
  Summary: CLI tool to automate ChipFoundry project submission to SFTP server
5
5
  Home-page: https://chipfoundry.io
6
6
  License: Apache-2.0
@@ -1,2 +1,2 @@
1
1
  """ChipFoundry CLI package: Automate project submission to SFTP."""
2
- __version__ = "2.4.6"
2
+ __version__ = "2.5.4"
@@ -3,7 +3,11 @@ import getpass
3
3
  import hashlib
4
4
  from typing import Optional, List, Tuple
5
5
  from chipfoundry_cli.check_refs import PRECHECK_CHECKS
6
- from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo
6
+ from chipfoundry_cli.remote_precheck_git import (
7
+ RemotePrecheckGitError,
8
+ verify_remote_job_repo,
9
+ verify_remote_precheck_repo,
10
+ )
7
11
  from chipfoundry_cli.version_check import maybe_warn_outdated
8
12
  from chipfoundry_cli.utils import (
9
13
  collect_project_files, ensure_cf_directory, update_or_create_project_json,
@@ -1731,6 +1735,39 @@ def _sha256_file(path: Path) -> str:
1731
1735
  return h.hexdigest()
1732
1736
 
1733
1737
 
1738
+ def _ordered_sftp_uploads(upload_map: dict) -> list:
1739
+ """Return payload files first and project.json last as the commit marker."""
1740
+ config_path = upload_map.get(".cf/project.json")
1741
+ if not config_path:
1742
+ raise ValueError("SFTP push requires .cf/project.json")
1743
+ payloads = [
1744
+ (rel_path, local_path)
1745
+ for rel_path, local_path in upload_map.items()
1746
+ if rel_path != ".cf/project.json" and local_path
1747
+ ]
1748
+ payloads.append((".cf/project.json", config_path))
1749
+ return payloads
1750
+
1751
+
1752
+ def _prepared_wrapper_hash(project_json_path: str) -> str:
1753
+ with open(project_json_path, "r") as f:
1754
+ data = json.load(f)
1755
+ value = str((data.get("project") or {}).get("user_project_wrapper_hash") or "").strip()
1756
+ if not value:
1757
+ raise ValueError("project.json is missing project.user_project_wrapper_hash")
1758
+ return value
1759
+
1760
+
1761
+ def _assert_wrapper_hash_unchanged(wrapper_path: str, expected_hash: str) -> None:
1762
+ current_hash = _sha256_file(Path(wrapper_path))
1763
+ if current_hash != expected_hash:
1764
+ raise RuntimeError(
1765
+ "Wrapper GDS changed while it was being uploaded. "
1766
+ "project.json was not uploaded; stop any process modifying the GDS "
1767
+ "and run cf push --force-overwrite again."
1768
+ )
1769
+
1770
+
1734
1771
  def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, str]]:
1735
1772
  """Return [(rel_path, abs_path, size, kind)] for files the platform
1736
1773
  accepts via --https.
@@ -2107,17 +2144,22 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
2107
2144
  cf_dir = ensure_cf_directory(project_root)
2108
2145
 
2109
2146
  # Find the GDS file path for hash calculation
2110
- gds_path = None
2111
- for gds_key, gds_path in collected.items():
2147
+ wrapper_path = None
2148
+ for gds_key, local_path in collected.items():
2112
2149
  if gds_key.startswith("gds/"):
2150
+ wrapper_path = local_path
2113
2151
  break
2152
+ if not wrapper_path:
2153
+ console.print("[red]No wrapper GDS found in collected project files.[/red]")
2154
+ raise click.Abort()
2114
2155
 
2115
2156
  project_json_path = update_or_create_project_json(
2116
2157
  cf_dir=str(cf_dir),
2117
- gds_path=gds_path,
2158
+ gds_path=wrapper_path,
2118
2159
  cli_overrides=cli_overrides,
2119
2160
  existing_json_path=collected.get(".cf/project.json")
2120
2161
  )
2162
+ prepared_wrapper_hash = _prepared_wrapper_hash(project_json_path)
2121
2163
 
2122
2164
  # SFTP upload or dry-run
2123
2165
  final_project_name = project_name or (
@@ -2131,16 +2173,14 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
2131
2173
  upload_map["verilog/rtl/user_defines.v"] = collected.get("verilog/rtl/user_defines.v")
2132
2174
 
2133
2175
  # Add the appropriate GDS file based on what was collected
2134
- for gds_key, gds_path in collected.items():
2176
+ for gds_key, local_path in collected.items():
2135
2177
  if gds_key.startswith("gds/"):
2136
- upload_map[gds_key] = gds_path
2178
+ upload_map[gds_key] = local_path
2137
2179
 
2138
2180
  if dry_run:
2139
2181
  console.print("[bold]Files to upload:[/bold]")
2140
- for rel_path, local_path in upload_map.items():
2141
- if local_path:
2142
- remote_path = os.path.join(sftp_base, rel_path)
2143
- console.print(f" {os.path.basename(local_path)} → {rel_path}")
2182
+ for rel_path, local_path in _ordered_sftp_uploads(upload_map):
2183
+ console.print(f" {os.path.basename(local_path)} → {rel_path}")
2144
2184
  return
2145
2185
 
2146
2186
  console.print(f"Connecting to {sftp_host}...")
@@ -2159,15 +2199,16 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n
2159
2199
  raise click.Abort()
2160
2200
 
2161
2201
  try:
2162
- for rel_path, local_path in upload_map.items():
2163
- if local_path:
2164
- remote_path = os.path.join(sftp_base, rel_path)
2165
- upload_with_progress(
2166
- sftp,
2167
- local_path=local_path,
2168
- remote_path=remote_path,
2169
- force_overwrite=force_overwrite
2170
- )
2202
+ for rel_path, local_path in _ordered_sftp_uploads(upload_map):
2203
+ if rel_path == ".cf/project.json":
2204
+ _assert_wrapper_hash_unchanged(wrapper_path, prepared_wrapper_hash)
2205
+ remote_path = os.path.join(sftp_base, rel_path)
2206
+ upload_with_progress(
2207
+ sftp,
2208
+ local_path=local_path,
2209
+ remote_path=remote_path,
2210
+ force_overwrite=force_overwrite
2211
+ )
2171
2212
  console.print(f"[green]✓ Uploaded to {sftp_base}[/green]")
2172
2213
 
2173
2214
  except Exception as e:
@@ -3587,10 +3628,12 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3587
3628
  check=True,
3588
3629
  capture_output=True
3589
3630
  )
3590
-
3591
- # Determine the python executable path in venv
3592
- venv_python = str(venv_cocotb / 'bin' / 'python3')
3593
-
3631
+
3632
+ venv_python = str(venv_cocotb / 'bin' / 'python3')
3633
+
3634
+ # Install (or reinstall) when the package is missing or --overwrite was used.
3635
+ # The venv may already exist from a previous partial failure.
3636
+ if not is_installed or overwrite:
3594
3637
  console.print("[cyan]Installing caravel-cocotb from source (chipfoundry/caravel-sim-infrastructure)...[/cyan]")
3595
3638
  subprocess.run(
3596
3639
  [venv_python, '-m', 'pip', 'install', '--upgrade', '--no-cache-dir', 'pip'],
@@ -3695,7 +3738,208 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3695
3738
  console.print("[bold green]Installation complete![/bold green]")
3696
3739
  else:
3697
3740
  console.print("[bold green]Setup complete![/bold green]")
3698
-
3741
+
3742
+
3743
+ def _is_nix_toolchain_noise_line(line: str) -> bool:
3744
+ """Match Nix fetch/build chatter posted by the PNR runner."""
3745
+ s = line.strip()
3746
+ if not s:
3747
+ return True
3748
+ bare = s[len("[stderr]") :].lstrip() if s.startswith("[stderr]") else s
3749
+ low = bare.lower()
3750
+ if low.startswith("copying path"):
3751
+ return True
3752
+ if low.startswith("building '/nix/store/") or low.startswith('building "/nix/store/'):
3753
+ return True
3754
+ if low.startswith("downloading '") or low.startswith('downloading "'):
3755
+ return True
3756
+ if low.startswith("unpacking 'github:") or low.startswith('unpacking "github:'):
3757
+ return True
3758
+ if "paths will be fetched" in low or "paths will be copied" in low:
3759
+ return True
3760
+ if low.startswith("copying ") and " path" in low:
3761
+ return True
3762
+ if low.startswith("waiting for lock") or low.startswith("waiting for locks"):
3763
+ return True
3764
+ if bare.startswith("/nix/store/") or bare.startswith(" /nix/store/"):
3765
+ return True
3766
+ return False
3767
+
3768
+
3769
+ def _print_remote_progress_message(msg: object, *, style: str = "dim") -> None:
3770
+ """Print worker progress, collapsing Nix toolchain noise into a one-liner."""
3771
+ text = str(msg)
3772
+ lines = text.splitlines()
3773
+ kept: List[str] = []
3774
+ noise = 0
3775
+ for line in lines:
3776
+ if _is_nix_toolchain_noise_line(line):
3777
+ noise += 1
3778
+ else:
3779
+ kept.append(line)
3780
+ if noise and not kept:
3781
+ console.print(
3782
+ Text(
3783
+ f"Nix toolchain resolving… ({noise} build/fetch lines suppressed)",
3784
+ style="dim",
3785
+ )
3786
+ )
3787
+ return
3788
+ if noise:
3789
+ kept.append(f"(also suppressed {noise} Nix build/fetch lines)")
3790
+ out = "\n".join(kept).rstrip()
3791
+ if out:
3792
+ console.print(Text(out, style=style))
3793
+
3794
+
3795
+ def _queue_and_maybe_poll_remote_job(
3796
+ *,
3797
+ create_path: str,
3798
+ job_get_path_template: str,
3799
+ params: list,
3800
+ dry_run: bool,
3801
+ poll: bool,
3802
+ wait_timeout: int,
3803
+ label: str,
3804
+ ) -> None:
3805
+ """POST a remote platform job and optionally poll until terminal status."""
3806
+ import time
3807
+ from urllib.parse import urlencode
3808
+
3809
+ import httpx as httpx_remote
3810
+
3811
+ if dry_run:
3812
+ console.print(f"[cyan]Would POST[/cyan] {create_path}?" + urlencode(params))
3813
+ return
3814
+ if poll and wait_timeout < 0:
3815
+ console.print(
3816
+ "[red]✗[/red] --wait-timeout must be >= 0 (0 means no limit while polling)."
3817
+ )
3818
+ raise SystemExit(1)
3819
+ config = load_user_config()
3820
+ api_key = config.get('api_key')
3821
+ if not api_key:
3822
+ console.print("[yellow]Not logged in.[/yellow] Run [bold]cf login[/bold] first.")
3823
+ raise SystemExit(1)
3824
+ api_url = _get_api_url()
3825
+ client = httpx_remote.Client(
3826
+ base_url=f"{api_url}/api/v1",
3827
+ headers={
3828
+ 'Authorization': f'Bearer {api_key}',
3829
+ 'User-Agent': _cf_user_agent(),
3830
+ },
3831
+ timeout=120.0,
3832
+ )
3833
+ try:
3834
+ resp = client.post(create_path, params=params)
3835
+ if resp.status_code == 401:
3836
+ console.print("[red]✗[/red] API key is invalid or expired. Run [bold]cf login[/bold].")
3837
+ raise SystemExit(1)
3838
+ if not resp.is_success:
3839
+ try:
3840
+ detail = resp.json().get("detail", resp.text)
3841
+ except Exception:
3842
+ detail = resp.text
3843
+ console.print(f"[red]✗[/red] {detail}")
3844
+ raise SystemExit(1)
3845
+ job = resp.json()
3846
+ jid = job["id"]
3847
+ st0 = job.get("status") or "unknown"
3848
+ if st0 == "failed":
3849
+ console.print(f"[cyan]{label}[/cyan] job_id={jid} status={st0}")
3850
+ elif st0 == "running":
3851
+ console.print(f"[cyan]{label} started[/cyan] job_id={jid} status={st0}")
3852
+ else:
3853
+ console.print(f"[cyan]Queued {label.lower()}[/cyan] job_id={jid} status={st0}")
3854
+ if job.get("status") == "failed" and job.get("error_message"):
3855
+ console.print(f"[red]✗[/red] {job['error_message']}")
3856
+ raise SystemExit(1)
3857
+ if job.get("status") == "completed":
3858
+ console.print(f"[green]✓[/green] {label} completed")
3859
+ if job.get("github_pr_url"):
3860
+ console.print(f" Pull request: {job['github_pr_url']}")
3861
+ return
3862
+ if not poll:
3863
+ console.print(
3864
+ f"[dim]Not waiting: use [bold]--remote --poll[/bold] to stream progress "
3865
+ f"([bold]--wait-timeout 0[/bold] = no time limit while polling).[/dim]"
3866
+ )
3867
+ return
3868
+ deadline = None if wait_timeout == 0 else time.monotonic() + wait_timeout
3869
+ if wait_timeout == 0:
3870
+ console.print("[dim]Polling until the job completes (no timeout).[/dim]")
3871
+ else:
3872
+ console.print(
3873
+ f"[dim]Polling every 5s; stops after {wait_timeout}s if still queued or running. "
3874
+ f"Use [bold]--wait-timeout 0[/bold] for no limit.[/dim]"
3875
+ )
3876
+ last_status_seen = st0
3877
+ terminal = None
3878
+ github_pr_url = None
3879
+ fail_message = None
3880
+ progress_emitted = 0
3881
+ get_path = job_get_path_template.format(jid=jid)
3882
+ console.print("[dim]Worker log batches appear below as the platform receives them (5s poll).[/dim]")
3883
+ while True:
3884
+ if deadline is not None and time.monotonic() > deadline:
3885
+ console.print(
3886
+ f"[yellow]⚠[/yellow] Timed out waiting for {label.lower()} (job still queued or running)."
3887
+ )
3888
+ console.print(
3889
+ f"[dim]job_id={jid} — open the project in the portal or run [bold]cf status[/bold].[/dim]"
3890
+ )
3891
+ console.print(
3892
+ "[dim]Cancel a stuck run in the portal, or retry with e.g. "
3893
+ "[bold]--remote --poll --wait-timeout 14400[/bold].[/dim]"
3894
+ )
3895
+ raise SystemExit(1)
3896
+ time.sleep(5)
3897
+ r2 = client.get(get_path)
3898
+ if r2.status_code == 401:
3899
+ console.print("[red]✗[/red] API key is invalid or expired.")
3900
+ raise SystemExit(1)
3901
+ r2.raise_for_status()
3902
+ j2 = r2.json()
3903
+ st = j2.get("status")
3904
+ prog = j2.get("progress")
3905
+ if isinstance(prog, list) and len(prog) > progress_emitted:
3906
+ for row in prog[progress_emitted:]:
3907
+ if not isinstance(row, dict):
3908
+ continue
3909
+ msg = row.get("message")
3910
+ if msg:
3911
+ _print_remote_progress_message(msg)
3912
+ progress_emitted = len(prog)
3913
+ if st == "completed":
3914
+ terminal = "completed"
3915
+ github_pr_url = j2.get("github_pr_url")
3916
+ break
3917
+ if st == "failed":
3918
+ terminal = "failed"
3919
+ fail_message = j2.get("error_message") or "unknown error"
3920
+ break
3921
+ if st != last_status_seen:
3922
+ console.print(
3923
+ f"[dim]… job status[/dim] [cyan]{st or 'unknown'}[/cyan]"
3924
+ )
3925
+ last_status_seen = st
3926
+
3927
+ if terminal == "completed":
3928
+ console.print(f"[green]✓[/green] {label} completed")
3929
+ if github_pr_url:
3930
+ console.print(f" Pull request: {github_pr_url}")
3931
+ elif terminal == "failed":
3932
+ console.print(f"[red]✗[/red] {label} failed: {fail_message}")
3933
+ raise SystemExit(1)
3934
+ except SystemExit:
3935
+ raise
3936
+ except Exception as e:
3937
+ console.print(f"[red]✗[/red] {label} request failed: {e}")
3938
+ raise SystemExit(1)
3939
+ finally:
3940
+ client.close()
3941
+
3942
+
3699
3943
  @main.command(
3700
3944
  'harden',
3701
3945
  cls=CategorizedCommand,
@@ -3704,6 +3948,7 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3704
3948
  ("Run Controls", ["tag", "from_step"]),
3705
3949
  ("GUI Modes", ["open_in_openroad", "open_in_klayout"]),
3706
3950
  ("Execution Backend", ["pdk", "use_nix", "use_docker"]),
3951
+ ("Remote", ["dry_run", "remote", "poll", "git_ref", "wait_timeout"]),
3707
3952
  ],
3708
3953
  )
3709
3954
  @click.argument('macro', required=False)
@@ -3717,8 +3962,46 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite,
3717
3962
  @click.option('--pdk', help='PDK to use (defaults to sky130A)')
3718
3963
  @click.option('--use-nix', is_flag=True, help='Force use of Nix (fails if Nix not available)')
3719
3964
  @click.option('--use-docker', is_flag=True, help='Force use of Docker (fails if Docker not available)')
3720
- def harden(macro, project_root, list_designs, list_from_steps, tag, from_step, open_in_openroad, open_in_klayout, pdk, use_nix, use_docker):
3721
- """Harden a macro using LibreLane (OpenLane 2)."""
3965
+ @click.option('--dry-run', is_flag=True, help='Show the configuration without running')
3966
+ @click.option('--remote', is_flag=True, help='Queue PNR on the ChipFoundry platform (requires cf login + linked project)')
3967
+ @click.option(
3968
+ '--poll',
3969
+ is_flag=True,
3970
+ help='With --remote: poll until the job finishes and print progress (5s interval).',
3971
+ )
3972
+ @click.option('--git-ref', default='main', show_default=True, help='Git branch or tag for remote PNR')
3973
+ @click.option(
3974
+ '--wait-timeout',
3975
+ type=int,
3976
+ default=7200,
3977
+ show_default=True,
3978
+ help='With --remote --poll: max seconds to wait (0 = no limit). Ignored without --poll.',
3979
+ )
3980
+ def harden(
3981
+ macro,
3982
+ project_root,
3983
+ list_designs,
3984
+ list_from_steps,
3985
+ tag,
3986
+ from_step,
3987
+ open_in_openroad,
3988
+ open_in_klayout,
3989
+ pdk,
3990
+ use_nix,
3991
+ use_docker,
3992
+ dry_run,
3993
+ remote,
3994
+ poll,
3995
+ git_ref,
3996
+ wait_timeout,
3997
+ ):
3998
+ """Harden a macro using LibreLane (OpenLane 2).
3999
+
4000
+ Examples:
4001
+ cf harden user_proj_example
4002
+ cf harden --list
4003
+ cf harden user_proj_example --remote --poll
4004
+ """
3722
4005
  from datetime import datetime
3723
4006
 
3724
4007
  # If .cf/project.json exists in cwd, use it as default project_root
@@ -3729,6 +4012,49 @@ def harden(macro, project_root, list_designs, list_from_steps, tag, from_step, o
3729
4012
  project_root = os.getcwd()
3730
4013
 
3731
4014
  project_root_path = Path(project_root)
4015
+
4016
+ if poll and not remote:
4017
+ console.print("[red]✗[/red] --poll requires --remote.")
4018
+ raise SystemExit(1)
4019
+
4020
+ if remote:
4021
+ if list_designs or not macro:
4022
+ console.print("[red]✗[/red] --remote requires a macro name (cannot use --list).")
4023
+ raise SystemExit(1)
4024
+ if list_from_steps or from_step or open_in_openroad or open_in_klayout:
4025
+ console.print(
4026
+ "[red]✗[/red] --remote cannot be combined with --from, --list-from-steps, or GUI modes."
4027
+ )
4028
+ raise SystemExit(1)
4029
+ if not check_project_initialized(project_root_path, 'harden', dry_run=dry_run, allow_graceful=True):
4030
+ console.print(f"[red]✗[/red] Project not initialized. Please run 'cf init' first.")
4031
+ return
4032
+ platform_id = _load_project_platform_id(str(project_root_path))
4033
+ if not platform_id:
4034
+ console.print(
4035
+ "[red]✗[/red] Link this repo to a platform project (set platform_project_id via [bold]cf link[/bold])."
4036
+ )
4037
+ raise SystemExit(1)
4038
+ try:
4039
+ verify_remote_job_repo(project_root_path, git_ref)
4040
+ except RemotePrecheckGitError as e:
4041
+ console.print(f"[red]✗[/red] {e}")
4042
+ raise SystemExit(1)
4043
+ remote_params = [("macro", macro), ("git_ref", git_ref)]
4044
+ if pdk:
4045
+ remote_params.append(("pdk", pdk))
4046
+ if tag:
4047
+ remote_params.append(("run_tag", tag))
4048
+ _queue_and_maybe_poll_remote_job(
4049
+ create_path=f"/projects/{platform_id}/pnr-jobs",
4050
+ job_get_path_template=f"/projects/{platform_id}/pnr-jobs/{{jid}}",
4051
+ params=remote_params,
4052
+ dry_run=dry_run,
4053
+ poll=poll,
4054
+ wait_timeout=wait_timeout,
4055
+ label="Remote PNR",
4056
+ )
4057
+ return
3732
4058
 
3733
4059
  # Check if project is initialized (skip check for --list or when no macro specified, allow graceful return)
3734
4060
  if not list_designs and macro:
@@ -4491,9 +4817,9 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run,
4491
4817
  isinstance(det, dict)
4492
4818
  and det.get("event") == "check_done"
4493
4819
  ):
4494
- console.print(Text(str(msg), style="bold"))
4820
+ _print_remote_progress_message(msg, style="bold")
4495
4821
  else:
4496
- console.print(Text(str(msg), style="dim"))
4822
+ _print_remote_progress_message(msg)
4497
4823
  progress_emitted = len(prog)
4498
4824
  if st == "completed":
4499
4825
  terminal = "completed"
@@ -4668,7 +4994,21 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run,
4668
4994
  @click.option('--all', 'run_all', is_flag=True, help='Run all tests')
4669
4995
  @click.option('--tag', help='Test list tag/yaml file (e.g., all_tests or user_proj_tests)')
4670
4996
  @click.option('--dry-run', is_flag=True, help='Show the configuration without running')
4671
- def verify(test, project_root, sim, list_tests, run_all, tag, dry_run):
4997
+ @click.option('--remote', is_flag=True, help='Queue simulation on the ChipFoundry platform (requires cf login + linked project)')
4998
+ @click.option(
4999
+ '--poll',
5000
+ is_flag=True,
5001
+ help='With --remote: poll until the job finishes and print progress (5s interval).',
5002
+ )
5003
+ @click.option('--git-ref', default='main', show_default=True, help='Git branch or tag for remote simulation')
5004
+ @click.option(
5005
+ '--wait-timeout',
5006
+ type=int,
5007
+ default=7200,
5008
+ show_default=True,
5009
+ help='With --remote --poll: max seconds to wait (0 = no limit). Ignored without --poll.',
5010
+ )
5011
+ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, poll, git_ref, wait_timeout):
4672
5012
  """Run cocotb verification tests.
4673
5013
 
4674
5014
  Examples:
@@ -4677,6 +5017,8 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run):
4677
5017
  cf verify counter_la --sim gl # Run gate-level simulation
4678
5018
  cf verify --all # Run all tests
4679
5019
  cf verify --tag all_tests # Run tests from a yaml list
5020
+ cf verify counter_la --remote
5021
+ cf verify --all --remote --poll
4680
5022
  """
4681
5023
  # If .cf/project.json exists in cwd, use it as default project_root
4682
5024
  cwd_root, _ = get_project_json_from_cwd()
@@ -4686,6 +5028,53 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run):
4686
5028
  project_root = os.getcwd()
4687
5029
 
4688
5030
  project_root_path = Path(project_root)
5031
+
5032
+ if poll and not remote:
5033
+ console.print("[red]✗[/red] --poll requires --remote.")
5034
+ raise SystemExit(1)
5035
+
5036
+ if remote:
5037
+ if list_tests:
5038
+ console.print("[red]✗[/red] --remote cannot be combined with --list.")
5039
+ raise SystemExit(1)
5040
+ if not check_project_initialized(project_root_path, 'verify', dry_run=dry_run, allow_graceful=True):
5041
+ console.print(f"[red]✗[/red] Project not initialized. Please run 'cf init' first.")
5042
+ return
5043
+ if not test and not run_all and not tag:
5044
+ console.print("[red]Error: Specify a test name, use --all, or --tag <test_list>[/red]")
5045
+ raise SystemExit(1)
5046
+ mode_count = int(bool(test)) + int(bool(run_all)) + int(bool(tag))
5047
+ if mode_count != 1:
5048
+ console.print("[red]Error: Use exactly one of: test name, --all, or --tag[/red]")
5049
+ raise SystemExit(1)
5050
+ platform_id = _load_project_platform_id(str(project_root_path))
5051
+ if not platform_id:
5052
+ console.print(
5053
+ "[red]✗[/red] Link this repo to a platform project (set platform_project_id via [bold]cf link[/bold])."
5054
+ )
5055
+ raise SystemExit(1)
5056
+ try:
5057
+ verify_remote_job_repo(project_root_path, git_ref)
5058
+ except RemotePrecheckGitError as e:
5059
+ console.print(f"[red]✗[/red] {e}")
5060
+ raise SystemExit(1)
5061
+ remote_params = [("git_ref", git_ref), ("sim_type", (sim or "rtl").lower())]
5062
+ if run_all:
5063
+ remote_params.append(("run_all", "true"))
5064
+ elif tag:
5065
+ remote_params.append(("test_list_tag", tag))
5066
+ else:
5067
+ remote_params.append(("test", test))
5068
+ _queue_and_maybe_poll_remote_job(
5069
+ create_path=f"/projects/{platform_id}/simulation-jobs",
5070
+ job_get_path_template=f"/projects/{platform_id}/simulation-jobs/{{jid}}",
5071
+ params=remote_params,
5072
+ dry_run=dry_run,
5073
+ poll=poll,
5074
+ wait_timeout=wait_timeout,
5075
+ label="Remote simulation",
5076
+ )
5077
+ return
4689
5078
 
4690
5079
  # Check if project is initialized (skip check if just listing tests, allow graceful return)
4691
5080
  if not list_tests:
@@ -228,6 +228,47 @@ def verify_remote_precheck_repo(
228
228
  )
229
229
 
230
230
 
231
+ def verify_remote_job_repo(project_root: Path, git_ref: str) -> None:
232
+ """
233
+ Thin git consistency check for remote harden / verify.
234
+
235
+ Ensures origin/{git_ref} tip matches HEAD. Does not require wrapper GDS or
236
+ other precheck layout inputs (those may not exist yet when hardening).
237
+ """
238
+ repo = project_root.resolve()
239
+ git_marker = repo / ".git"
240
+ if not (git_marker.is_dir() or git_marker.is_file()):
241
+ raise RemotePrecheckGitError(
242
+ "Remote jobs require a git checkout with .git "
243
+ "(clone your GitHub repo, not a plain folder copy)."
244
+ )
245
+
246
+ remote_sha = _resolve_origin_tip_sha(repo, git_ref)
247
+ head_sha = _local_head_sha(repo)
248
+ if head_sha != remote_sha:
249
+ raise RemotePrecheckGitError(
250
+ f"Local HEAD ({head_sha[:7]}) must match origin {git_ref!r} ({remote_sha[:7]}). "
251
+ f"git checkout {git_ref} && git pull, or push your commits, then retry."
252
+ )
253
+
254
+ # If .cf/project.json is tracked, require it clean so the remote clone matches.
255
+ if _path_tracked_in_git(repo, CF_PROJECT_JSON_REL):
256
+ dirty = _porcelain_paths(repo)
257
+ for entry in dirty:
258
+ path = entry[2:] if entry.startswith("??") else entry
259
+ if path == CF_PROJECT_JSON_REL:
260
+ raise RemotePrecheckGitError(
261
+ f"{CF_PROJECT_JSON_REL!r} has uncommitted changes. "
262
+ "Commit or stash before queueing a remote job."
263
+ )
264
+ r = _run_git(repo, "diff-index", "--quiet", "HEAD", "--", CF_PROJECT_JSON_REL)
265
+ if r.returncode != 0:
266
+ raise RemotePrecheckGitError(
267
+ f"{CF_PROJECT_JSON_REL!r} has uncommitted changes. "
268
+ "Commit or stash before queueing a remote job."
269
+ )
270
+
271
+
231
272
  class RemotePushGitError(Exception):
232
273
  """Local repository state is not consistent with origin for remote push."""
233
274
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "2.5.1"
3
+ version = "2.5.4"
4
4
  description = "CLI tool to automate ChipFoundry project submission to SFTP server"
5
5
  authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
6
6
  readme = "README.md"
File without changes