Add ci deploy with hardhat (#5)

* Add deploy workflow with hardhat

* Rm hardhat deployments dir

* Add contract deploy script

* Add dev goodies for quick use during development

* Update deps

* Use custom script for contract deployment

* Add info about deploy.ts

* Add step to show docker logs after contract deployment

* Add building of new image with deployed contract

* Add dynamic wait for geth to be up instead of sleep

* Print container logs while waiting for geth

* Update container cmd

* dbg

* dbg

* Update args

* Fix push image

* dbg

* Fix build command typo

* Update README

* Update README

* Allow deploy workflow to run only when PR is merged in master and has label CI:Deploy
This commit is contained in:
Alexander Parvanov 2025-04-08 16:36:03 +03:00 committed by GitHub
parent a6ac032f7a
commit 7df5aa3c92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 10122 additions and 1 deletions

80
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,80 @@
name: deploy
on:
pull_request:
types: [closed]
branches: [master]
paths: ["**"]
jobs:
deploy:
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'CI:Deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: prep tags
id: vars
run: |
echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
echo "IMAGE_NAME=${{ secrets.DOCKERHUB_USERNAME }}/geth-dev" >> $GITHUB_ENV
- name: start geth
run: |
mkdir -p geth_data
docker run -d --name geth-service -p 8545:8545 -p 30303:30303 -v "$(pwd)/geth_data:/root/.ethereum" yyx00xzz/geth-dev:latest --dev --http --http.addr 0.0.0.0 --http.port 8545 --http.api admin,debug,web3,eth,txpool,miner,net,dev --miner.gasprice "1" --http.corsdomain "*"
echo "Waiting for Geth to be ready..."
# Poll the Geth RPC endpoint until it's responsive
while ! curl -s -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"web3_clientVersion","params":[],"id":1}' http://localhost:8545 > /dev/null; do
echo "Geth not ready yet, waiting..."
docker logs geth-service
sleep 5
done
echo "Geth is ready."
- name: install hardhat deps
run: |
cd ./hardhat
npm install
- name: deploy contract
run: |
cd ./hardhat
npx hardhat compile
npx hardhat run scripts/deploy.ts --network geth
- name: get docker logs
run: docker logs geth-service
- name: stop geth
run: docker stop geth-service
- name: build new image with deployed contract
run: |
pwd
cat <<EOF > Dockerfile.with-contract
FROM yyx00xzz/geth-dev:latest
COPY geth_data /root/.ethereum
EOF
docker build -t $IMAGE_NAME:with-contract-latest -t $IMAGE_NAME:with-contract-$COMMIT_SHA -f Dockerfile.with-contract .
rm Dockerfile.with-contract
docker images -a
- name: push image
run: |
docker push $IMAGE_NAME:with-contract-latest
docker push $IMAGE_NAME:with-contract-$COMMIT_SHA

20
.gitignore vendored
View file

@ -55,4 +55,22 @@ cmd/ethkey/ethkey
cmd/evm/evm
cmd/geth/geth
cmd/rlpdump/rlpdump
cmd/workload/workload
cmd/workload/workload
hardhat/node_modules
hardhat/.env
# Hardhat files
hardhat/cache
hardhat/artifacts
# TypeChain files
hardhat/typechain
hardhat/typechain-types
# solidity-coverage files
hardhat/coverage
hardhat/coverage.json
# Hardhat Ignition default folder for deployments against a local node
hardhat/ignition/deployments/chain-31337

View file

@ -1,9 +1,32 @@
## DevOps docs
All workflows are limited to PRs with target=master for the sake of simplicity in this demo.
In the future, a feature should be implemented which allows to build images and deploy contracts from feature branches.
### Build
When a PR with label CI:Build is merged in the master branch, a Docker image is build and pushed tagged with latest AND with the commit ID. This way you can refer to older version if needed but at the same time latest is available.
!NB If you encounter a problem with pushing to the registry, check if the access token has not expired. It has 30 days expiration.
### Deploy
#### Deploying the contract
Although hardhat ignition works perfectly with the hardhat network, there are issues when trying to deploy to Geth.
To solve this, there is deploy script in `hardhat/scripts` dir which deploys the sample Lock contract to the blockchain.
If you need to deploy the contract locally:
1. cd to `hardhat` dir
2. run `npx hardhat run scripts/deploy.ts --network geth`
---
#### Creating new image containing the contract
I've decided to go with mounted dir for the data dir because it is easier to create new dokcer image with the contract already deployed. If I've stayed on the volume mount, it would require more steps to persist the data dir.
Using mounted dir for the data can be tricky because of permission errs tho.
Currently, the new image (with contract) is built from the latest base image.
This should be extended in the future to support building from a given tag.
## Go Ethereum
Golang execution layer implementation of the Ethereum protocol.

21
docker-compose.yml Normal file
View file

@ -0,0 +1,21 @@
version: '3.8'
services:
geth:
image: yyx00xzz/geth-dev:latest
ports:
- "8545:8545"
- "30303:30303"
command: >
--dev
--http
--http.addr 0.0.0.0
--http.port 8545
--http.api admin,debug,web3,eth,txpool,miner,net,dev
--miner.gasprice "1"
--http.corsdomain "*"
volumes:
- geth_data:/root/.ethereum
volumes:
geth_data:

13
hardhat/README.md Normal file
View file

@ -0,0 +1,13 @@
# Sample Hardhat Project
This project demonstrates a basic Hardhat use case. It comes with a sample contract, a test for that contract, and a Hardhat Ignition module that deploys that contract.
Try running some of the following tasks:
```shell
npx hardhat help
npx hardhat test
REPORT_GAS=true npx hardhat test
npx hardhat node
npx hardhat ignition deploy ./ignition/modules/Lock.ts
```

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
// Uncomment this line to use console.log
// import "hardhat/console.sol";
contract Lock {
uint public unlockTime;
address payable public owner;
event Withdrawal(uint amount, uint when);
constructor(uint _unlockTime) payable {
require(
block.timestamp < _unlockTime,
"Unlock time should be in the future"
);
unlockTime = _unlockTime;
owner = payable(msg.sender);
}
function withdraw() public {
// Uncomment this line, and the import of "hardhat/console.sol", to print a log in your terminal
// console.log("Unlock time is %o and block timestamp is %o", unlockTime, block.timestamp);
require(block.timestamp >= unlockTime, "You can't withdraw yet");
require(msg.sender == owner, "You aren't the owner");
emit Withdrawal(address(this).balance, block.timestamp);
owner.transfer(address(this).balance);
}
}

View file

@ -0,0 +1,14 @@
const Web3 = require('web3');
const fs = require('fs');
// Load the keystore file
const keyfile = fs.readFileSync('Full path to JSON key file', 'utf-8');
// Your password
const password = 'password';
const web3 = new Web3();
const account = web3.eth.accounts.decrypt(JSON.parse(keyfile), password);
// Print the private key
console.log('Private Key:', account.privateKey);

View file

@ -0,0 +1,7 @@
const { ethers } = require("ethers");
// Create a random wallet
const wallet = ethers.Wallet.createRandom();
console.log("Private Key:", wallet.privateKey);
console.log("Address:", wallet.address);

14
hardhat/hardhat.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
const config: HardhatUserConfig = {
solidity: "0.8.28",
networks: {
geth: {
url: "http://127.0.0.1:8545",
chainId: 1337
}
}
};
export default config;

View file

@ -0,0 +1,20 @@
// This setup uses Hardhat Ignition to manage smart contract deployments.
// Learn more about it at https://hardhat.org/ignition
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
const JAN_1ST_2030 = 1893456000;
const ONE_GWEI: bigint = 1_000_000_000n;
const LockModule = buildModule("LockModule", (m) => {
const unlockTime = m.getParameter("unlockTime", JAN_1ST_2030);
const lockedAmount = m.getParameter("lockedAmount", ONE_GWEI);
const lock = m.contract("Lock", [unlockTime], {
value: lockedAmount,
});
return { lock };
});
export default LockModule;

9695
hardhat/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
hardhat/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "hardhat",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@nomicfoundation/hardhat-ethers": "^3.0.8",
"@nomicfoundation/hardhat-toolbox": "^5.0.0",
"hardhat": "^2.22.19"
},
"dependencies": {
"ethers": "^6.13.5",
"web3": "^4.16.0"
}
}

23
hardhat/scripts/deploy.ts Normal file
View file

@ -0,0 +1,23 @@
import { ethers } from "hardhat";
async function main() {
const unlockTime = Math.floor(Date.now() / 1000) + 60;
const lockedAmount = ethers.parseEther("0.01");
const Lock = await ethers.getContractFactory("Lock");
const lock = await Lock.deploy(unlockTime, { value: lockedAmount });
await lock.waitForDeployment();
const lockAddress = await lock.getAddress(); // Use getAddress() for type safety
console.log(
`Lock with ${ethers.formatEther(
await ethers.provider.getBalance(lockAddress)
)} ETH and unlock timestamp ${unlockTime} deployed to ${lockAddress}`
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

127
hardhat/test/Lock.ts Normal file
View file

@ -0,0 +1,127 @@
import {
time,
loadFixture,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs";
import { expect } from "chai";
import hre from "hardhat";
describe("Lock", function () {
// We define a fixture to reuse the same setup in every test.
// We use loadFixture to run this setup once, snapshot that state,
// and reset Hardhat Network to that snapshot in every test.
async function deployOneYearLockFixture() {
const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
const ONE_GWEI = 1_000_000_000;
const lockedAmount = ONE_GWEI;
const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;
// Contracts are deployed using the first signer/account by default
const [owner, otherAccount] = await hre.ethers.getSigners();
const Lock = await hre.ethers.getContractFactory("Lock");
const lock = await Lock.deploy(unlockTime, { value: lockedAmount });
return { lock, unlockTime, lockedAmount, owner, otherAccount };
}
describe("Deployment", function () {
it("Should set the right unlockTime", async function () {
const { lock, unlockTime } = await loadFixture(deployOneYearLockFixture);
expect(await lock.unlockTime()).to.equal(unlockTime);
});
it("Should set the right owner", async function () {
const { lock, owner } = await loadFixture(deployOneYearLockFixture);
expect(await lock.owner()).to.equal(owner.address);
});
it("Should receive and store the funds to lock", async function () {
const { lock, lockedAmount } = await loadFixture(
deployOneYearLockFixture
);
expect(await hre.ethers.provider.getBalance(lock.target)).to.equal(
lockedAmount
);
});
it("Should fail if the unlockTime is not in the future", async function () {
// We don't use the fixture here because we want a different deployment
const latestTime = await time.latest();
const Lock = await hre.ethers.getContractFactory("Lock");
await expect(Lock.deploy(latestTime, { value: 1 })).to.be.revertedWith(
"Unlock time should be in the future"
);
});
});
describe("Withdrawals", function () {
describe("Validations", function () {
it("Should revert with the right error if called too soon", async function () {
const { lock } = await loadFixture(deployOneYearLockFixture);
await expect(lock.withdraw()).to.be.revertedWith(
"You can't withdraw yet"
);
});
it("Should revert with the right error if called from another account", async function () {
const { lock, unlockTime, otherAccount } = await loadFixture(
deployOneYearLockFixture
);
// We can increase the time in Hardhat Network
await time.increaseTo(unlockTime);
// We use lock.connect() to send a transaction from another account
await expect(lock.connect(otherAccount).withdraw()).to.be.revertedWith(
"You aren't the owner"
);
});
it("Shouldn't fail if the unlockTime has arrived and the owner calls it", async function () {
const { lock, unlockTime } = await loadFixture(
deployOneYearLockFixture
);
// Transactions are sent using the first signer by default
await time.increaseTo(unlockTime);
await expect(lock.withdraw()).not.to.be.reverted;
});
});
describe("Events", function () {
it("Should emit an event on withdrawals", async function () {
const { lock, unlockTime, lockedAmount } = await loadFixture(
deployOneYearLockFixture
);
await time.increaseTo(unlockTime);
await expect(lock.withdraw())
.to.emit(lock, "Withdrawal")
.withArgs(lockedAmount, anyValue); // We accept any value as `when` arg
});
});
describe("Transfers", function () {
it("Should transfer the funds to the owner", async function () {
const { lock, unlockTime, lockedAmount, owner } = await loadFixture(
deployOneYearLockFixture
);
await time.increaseTo(unlockTime);
await expect(lock.withdraw()).to.changeEtherBalances(
[owner, lock],
[lockedAmount, -lockedAmount]
);
});
});
});
});

11
hardhat/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
}
}