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
37 changes: 2 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,13 @@ Key requirements and notes:
- "OP" → `op.json`
- **Task origin signatures**: By default, tasks require three signature files stored per-network in `tasks/<task-name>/signatures/<network>/`: `creator-signature.json`, `base-facilitator-signature.json`, and `base-sc-facilitator-signature.json`. Signatures are stored separately from the config directory (which is what the tarball is signed over) so signing does not change the signed content. Use the `genTaskOriginSig.ts` script to generate these. Set `skipTaskOriginValidation: true` in the validation config to opt out.
- **Script execution**: The tool executes Foundry from `active/evm`. Ensure your Foundry project or script context is available under the per-network config directory (`tasks/<task-name>/config/<network>/`); the tool will run the `cmd` specified in your validation config. Temporary outputs like `temp-script-output.txt` will be written there.
- **Optional README parsing**: If `tasks/<task-name>/config/<network>/README.md` exists, the tool may parse it to display status and execution links.
- **Optional README parsing**: If `tasks/<task-name>/config/<network>/README.md` exists, the tool may parse it to display status.

### Task README structure

When present, each task's `README.md` is parsed to populate the UI. Place it at `active/evm/tasks/<YYYY-MM-DD-slug>/config/<network>/README.md` and follow these rules:

- **Status line (required for status display)**: Include a line containing `Status:` within the first 20 lines. Recognized values are `PENDING`, `READY TO SIGN`, and `EXECUTED` (case-insensitive). If `EXECUTED` is not present and `READY TO SIGN` is not present, the status is treated as `PENDING`. Supported formats:
- **Single-line with link (any one of these)**:
- `Status: EXECUTED (https://explorer/tx/0x...)`
- `Status: [EXECUTED](https://explorer/tx/0x...)`
- `Status: EXECUTED https://explorer/tx/0x...`
- **Multi-line links (up to 5 lines after the status line)**:
- First line: `Status: EXECUTED`
- Next lines: `Label: https://...` (e.g., `Transaction: https://...`, `Proposal: https://...`)
- **Status line (required for status display)**: Include a line containing `Status:` within the first 20 lines. Recognized values are `PENDING`, `READY TO SIGN`, and `EXECUTED` (case-insensitive). If `EXECUTED` is not present and `READY TO SIGN` is not present, the status is treated as `PENDING`.

- **Description (optional but recommended)**: Add a `## Description` section. The first paragraph after this header is shown in the UI and should be concise (aim for ≤150 chars). Formatting like bold, italics, and inline code is stripped.

Expand Down Expand Up @@ -122,32 +115,6 @@ Status: READY TO SIGN
Multisig proposal prepared; awaiting signatures from designated signers.
```

Executed with single-line link:

```markdown
# Upgrade System Config

Status: EXECUTED (https://explorer/tx/0xabc123)

## Description

Executed upgrade to System Config with no parameter changes to gas config.
```

Executed with multiple labeled links:

```markdown
# Upgrade System Config

Status: EXECUTED
Transaction: https://explorer/tx/0xabc123
Proposal: https://snapshot.org/#/proposal/0xdef456

## Description

Executed upgrade; links include both onchain transaction and governance proposal.
```

### Validation file structure

Validation configs live under each task directory at `active/evm/tasks/<YYYY-MM-DD-slug>/config/<network>/validations/` and are selected by user type:
Expand Down
59 changes: 2 additions & 57 deletions src/components/UpgradeSelection.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { TaskStatus, Upgrade, ExecutionLink } from '@/lib/types';
import { Upgrade } from '@/lib/types';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { MarkdownText } from './MarkdownText';
import { Card } from './ui/Card';
import { Badge } from './ui/Badge';
import { SectionHeader } from './ui/SectionHeader';

const STATUS_VARIANT_MAP: Record<TaskStatus, 'success' | 'warning' | 'neutral'> = {
[TaskStatus.Executed]: 'success',
[TaskStatus.ReadyToSign]: 'warning',
[TaskStatus.Pending]: 'neutral',
};

interface UpgradeSelectionProps {
onSelect: (upgrade: Upgrade) => void;
}
Expand Down Expand Up @@ -179,7 +173,7 @@ export const UpgradeSelection: React.FC<UpgradeSelectionProps> = ({ onSelect })
</div>

<div className="flex flex-col items-end gap-3 shrink-0">
<StatusBadge status={option.status} executionLinks={option.executionLinks} />
<Badge variant="warning">{option.status}</Badge>
</div>
</div>
</Card>
Expand All @@ -189,52 +183,3 @@ export const UpgradeSelection: React.FC<UpgradeSelectionProps> = ({ onSelect })
</div>
);
};

const StatusBadge: React.FC<{
status?: TaskStatus;
executionLinks?: ExecutionLink[];
}> = ({ status, executionLinks }) => {
if (!status) return null;

const variant = STATUS_VARIANT_MAP[status] || 'neutral';

if (status === TaskStatus.Executed && executionLinks && executionLinks.length > 0) {
return (
<div className="relative group/dropdown">
<Badge variant={variant} className="cursor-pointer hover:opacity-80">
{status} {executionLinks.length > 1 && `(${executionLinks.length})`}
<svg
className="ml-1 w-3 h-3 inline-block"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</Badge>

<div className="absolute right-0 top-full mt-1 w-48 py-1 bg-white rounded-md shadow-lg border border-gray-100 opacity-0 invisible group-hover/dropdown:opacity-100 group-hover/dropdown:visible transition-all duration-200 z-10">
{executionLinks.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={e => e.stopPropagation()}
>
{link.label}
</a>
))}
</div>
</div>
);
}

return <Badge variant={variant}>{status}</Badge>;
};
49 changes: 11 additions & 38 deletions src/lib/__tests__/deployments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
import {
getUpgradeOptions,
normalizeUrl,
resetContractDeploymentsRootCacheForTests,
} from '../deployments';
import { getUpgradeOptions, resetContractDeploymentsRootCacheForTests } from '../deployments';
import { NetworkType, TaskStatus } from '../types';

let originalCwd: string;
Expand All @@ -24,37 +20,6 @@ afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});

describe('normalizeUrl', () => {
it.each([
['https://etherscan.io/tx/0x123', 'https://etherscan.io/tx/0x123'],
['http://example.com/path', 'http://example.com/path'],
[' https://example.com ', 'https://example.com'],
['https://example.com/path).', 'https://example.com/path'],
['https://example.com/path],', 'https://example.com/path'],
['https://example.com/path...', 'https://example.com/path'],
['https://example.com/path?foo=bar&baz=qux', 'https://example.com/path?foo=bar&baz=qux'],
['https://example.com/path#section', 'https://example.com/path#section'],
['https://example.com:8080/path', 'https://example.com:8080/path'],
['https://user:pass@example.com/path', 'https://user:pass@example.com/path'],
])('normalizes %p to %p', (input, expected) => {
expect(normalizeUrl(input)).toBe(expected);
});

it.each([
'',
'http',
'http://',
'httpxyz://not-valid',
'http:not-a-url',
'not a url at all',
'ftp://files.example.com',
'javascript:alert(1)',
'file:///etc/passwd',
])('rejects %p', input => {
expect(normalizeUrl(input)).toBeUndefined();
});
});

describe('getUpgradeOptions', () => {
async function makeTask(id: string, network: string, readme?: string) {
const taskDir = path.join(tempDir, 'active', 'evm', 'tasks', id);
Expand Down Expand Up @@ -104,13 +69,21 @@ describe('getUpgradeOptions', () => {
expect(upgrades).toEqual([expect.objectContaining({ status: TaskStatus.Pending })]);
});

it('omits execution links from serialized executed tasks with no URLs', async () => {
it('returns executed status for a task with an executed README status', async () => {
await makeTask('2025-06-04-upgrade-foo', 'mainnet', 'Status: EXECUTED');

const upgrades = getUpgradeOptions(NetworkType.Mainnet);

expect(upgrades).toEqual([expect.objectContaining({ status: TaskStatus.Executed })]);
expect(JSON.parse(JSON.stringify(upgrades[0]))).not.toHaveProperty('executionLinks');
});

it('omits status when the README has no status line', async () => {
await makeTask('2025-06-04-upgrade-foo', 'mainnet', '# Upgrade Foo');

const upgrades = getUpgradeOptions(NetworkType.Mainnet);

expect(upgrades).toHaveLength(1);
expect(JSON.parse(JSON.stringify(upgrades[0]))).not.toHaveProperty('status');
});

it('returns all active task folders that have validations for the selected network', async () => {
Expand Down
67 changes: 5 additions & 62 deletions src/lib/deployments.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import { NetworkType, TaskStatus, Upgrade, ExecutionLink } from './types';
import { NetworkType, TaskStatus, Upgrade } from './types';

let cachedRoot: string | null = null;

Expand Down Expand Up @@ -42,7 +42,6 @@ export function findContractDeploymentsRoot(): string {

const DEFAULT_DESCRIPTION = 'Smart contract upgrade deployment';
const MAX_STATUS_SEARCH_LINES = 20;
const MAX_STATUS_FOLLOW_UP_LINES = 5;

function formatUpgradeName(folderName: string): string {
const slug = folderName.replace(/^\d{4}-\d{2}-\d{2}-/, '');
Expand Down Expand Up @@ -97,24 +96,7 @@ function extractDescription(content: string): string {
}
}

export function normalizeUrl(rawUrl: string): string | undefined {
if (!rawUrl) return undefined;
const trimmed = rawUrl.trim().replace(/[)\].,]+$/, '');
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
return undefined;
}
try {
new URL(trimmed);
return trimmed;
} catch {
return undefined;
}
}

function parseExecutionStatus(content: string): {
status?: TaskStatus;
executionLinks?: ExecutionLink[];
} {
function parseExecutionStatus(content: string): { status?: TaskStatus } {
try {
const lines = content.replace(/\r\n/g, '\n').split('\n');
const statusLineIndex = lines
Expand All @@ -129,46 +111,8 @@ function parseExecutionStatus(content: string): {
const isExecuted = normalizedStatus.includes(TaskStatus.Executed);
const isReady = normalizedStatus.includes(TaskStatus.ReadyToSign);

if (!isExecuted) return { status: isReady ? TaskStatus.ReadyToSign : TaskStatus.Pending };

const executionLinks: ExecutionLink[] = [];
const seenUrls = new Set<string>();

const addLink = (label: string, maybeUrl: string | undefined) => {
const url = maybeUrl ? normalizeUrl(maybeUrl) : undefined;
if (!url || seenUrls.has(url)) return;
seenUrls.add(url);
executionLinks.push({ label: label || 'Transaction', url });
};

for (const match of statusLine.matchAll(/https?:\/\/[^\s)\]]+/g)) {
addLink('Transaction', match[0]);
}

for (
let i = statusLineIndex + 1;
i < lines.length && i <= statusLineIndex + MAX_STATUS_FOLLOW_UP_LINES;
i++
) {
const rawLine = lines[i].trim();
if (!rawLine) break;
if (rawLine.startsWith('#')) break;

const labelledMatch = rawLine.match(/^([^:]+):\s*(https?:\/\/\S+)/i);
if (labelledMatch) {
addLink(labelledMatch[1].trim(), labelledMatch[2]);
continue;
}

const urlMatch = rawLine.match(/https?:\/\/\S+/);
if (urlMatch) {
addLink('Transaction', urlMatch[0]);
}
}

return executionLinks.length > 0
? { status: TaskStatus.Executed, executionLinks }
: { status: TaskStatus.Executed };
if (isExecuted) return { status: TaskStatus.Executed };
return { status: isReady ? TaskStatus.ReadyToSign : TaskStatus.Pending };
} catch (error) {
console.error('parseExecutionStatus error:', error);
return {};
Expand Down Expand Up @@ -214,13 +158,12 @@ function getUpgradeOption(

try {
const content = fs.readFileSync(readmePath, 'utf-8');
const { status, executionLinks } = parseExecutionStatus(content);
const { status } = parseExecutionStatus(content);
return {
...baseInfo,
name: extractTitle(content) || baseInfo.name,
description: extractDescription(content),
status,
executionLinks,
};
} catch (parseError) {
console.error(`Error parsing ${readmePath}:`, parseError);
Expand Down
6 changes: 0 additions & 6 deletions src/lib/types/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,11 @@ export enum TaskStatus {
Pending = 'PENDING',
}

export interface ExecutionLink {
url: string;
label: string;
}

export interface Upgrade {
id: string;
name: string;
description: string;
date: string;
network: NetworkType;
status?: TaskStatus;
executionLinks?: ExecutionLink[];
}
Loading