Building a Permanently Verifiable Blockchain Blog from Scratch: Polygon + Hexo + GitHub Actions in Practice
·(edited)· / , , , · reads· likes0Assisted · Title
AI TranslationSimplified ChineseEnglish
Key Insights
This approach uses Hexo to generate the blog, GitHub Actions for automated builds, Polygon PoS to store publication records, and GitHub Pages for normal access. Two contracts archive the homepage and posts respectively, recording content hashes, versions, and timestamps. Posts use deterministic JSON with gzip+Base64; keccak256 is computed to avoid duplicate on-chain writes. Contracts use CREATE2 for fixed addresses; before publishing, check the on-chain hash and estimate gas. The homepage must exclude dynamic fields such as build time before hashing. Private keys are stored only in Actions Secrets. Be aware of testnet limits, RPC instability, and the inability to permanently archive remote images.
A typical static blog solves the problem of 'how to publish', but does not fully solve the problem of 'how to prove': whether an article has been modified, when a particular version actually appeared, and whether the original content can still be verified after the site goes offline all depend on the site owner and the hosting platform.
So this is the approach: Hexo generates the site, GitHub Actions handles the automated build, Polygon PoS stores the homepage and article data, and GitHub Pages serves up a normal browsing experience.
Final architecture
The whole publishing process can be summarized as:
There are two independent contracts here:
HomepageArchive stores the homepage HTML, content hash, publish time, and version number.
ArticleArchive is keyed by the hash of the article slug and stores the article JSON, content hash, and version information.
The pages are still loaded from a CDN or GitHub Pages, so loading speed is no different from a normal Hexo blog. The blockchain provides a public, verifiable publication record that cannot be silently rewritten.
Setting up the environment
Locally you need Node.js, pnpm, Git, and a Polygon wallet:
BASH
node --version
pnpm --version
git --version
Install dependencies and verify Hexo:
BASH
pnpm install --frozen-lockfile
pnpm clean
pnpm build
pnpm server
Production publishing uses the Polygon mainnet, whose chain ID is 137, and the gas token is POL. I recommend creating a separate wallet used only for publishing rather than using the wallet that holds your main assets.
Design the on-chain article format first
An article can't just stuff Hexo's generated HTML into the contract as-is. The HTML mixes in theme structure, build metadata, and styling details, so a small theme adjustment would make every article produce a new version.
content is not ordinary Markdown; it is the result of the following processing:
Normalize line endings to LF, trim leading and trailing whitespace, and ensure the file ends with exactly one newline.
Compress the Markdown with gzip at level 9.
Encode the compressed result as Base64 so the JSON can be serialized deterministically.
Encode the entire JSON as UTF-8 and write it into the contract's bytes.
Determinism matters a lot. The same article must yield the same byte sequence whether it is built on Windows or Linux. Otherwise, every GitHub Actions run could mistake it for new content and pay gas for nothing.
The article content hash is not computed directly over the compressed Base64; instead, it computes keccak256 over the semantic JSON that contains the normalized Markdown. This way, the hash represents the article's content itself rather than the output details of a particular compressor version.
Local images can be read as real binary data and hashed, but remote images have a frequently overlooked problem: the build machine may not be able to download them reliably, and the remote may enforce hotlink protection, time out, or serve content that changes dynamically.
The current implementation computes an 'MD5 of the image URL string' for external images, which only identifies which address the article references; it cannot prove the content returned at that address, nor guarantee that the image will remain permanently available.
If you want strict, permanent image archiving, you can choose one of the following:
Upload the image to IPFS and write the CID into the article.
Use Arweave to store the image binary.
Download the image during publishing, compute its SHA-256 or Keccak-256, and sync the file to immutable object storage.
Put very small images directly on-chain, though the cost is usually unreasonable.
Therefore, 'the article's Markdown is on-chain' and 'all resources referenced by the article are permanently preserved' are two different things.
Designing the Solidity contract
The core structure of the article contract is not complicated:
When writing, it doesn't directly use the string slug; instead, it first computes:
JavaScript
const slugHash = keccak256(toUtf8Bytes(slug));
The contract's publish function has to perform several checks:
SOLIDITY
function publish(
bytes32 slugHash,
bytes calldata payload,
bytes32 contentHash
) external {
if (msg.sender != owner) revert NotOwner();
if (payload.length == 0) revert EmptyPayload();
if (payload.length > MAX_ARTICLE_BYTES) revert PayloadTooLarge(...);
if (publications[slugHash].contentHash == contentHash) {
revert UnchangedContent();
}
// 保存数据、增加版本,并发出事件
}
I limit a single article to 24,576 bytes. The limit applies to the UTF-8 byte length of the final JSON, not the size of the Markdown file. gzip shrinks the body, but Base64 adds about a third back, so be sure to check the final payload before sending the transaction.
The contract stores two hashes at the same time:
contentHash is used to determine whether the article's semantic content has changed.
payloadHash is the result of hashing the actually written bytes with keccak256, used to verify the raw on-chain bytes.
What does 'permanent' actually mean?
The current structure overwrites publications[slugHash].payload on every update, so the contract's read interface can only return the latest version. Older versions still exist in the blockchain history, transaction calldata, and events, but they cannot be queried directly through the current mapping.
If you need every historical version to be directly readable from a contract method, it should be changed to:
The cost is continuously growing contract storage and higher gas. For a personal blog, keeping the latest readable version while relying on transaction history to prove older versions is a more practical trade-off.
Using CREATE2 to fix the contract address
CI can't depend on a deployment result saved locally, so I use CREATE2 to precompute the contract address:
initCode contains the contract bytecode and constructor arguments. As long as the deployer, salt, compiled output, and owner are exactly identical, the final address will be identical. The publish script first calls eth_getCode: if there is no code at the address, it deploys; if there is code, it reuses it directly.
The catch here is that changing the publishing wallet also changes the contract address, because owner is part of the constructor arguments. Upgrading the Solidity version, changing the optimizer settings, adjusting the contract code, or changing the salt will likewise produce a new address. Don't casually change these values without a migration plan.
How the publish script avoids duplicate transactions
The article publish script scans source/_posts and requires the filename to match the numeric slug:
The numeric slug must be a positive integer and must be unique. It serves both as part of the page's permanent link and as the logical primary key for the article on-chain. After publishing, don't move the article to a different slug, or the chain will treat it as a brand-new article.
For each article, the script first reads the on-chain record:
This means each CI run can scan all articles, but only newly added or actually modified articles will send a transaction. For unchanged articles, the script queries historical events by the publish block, recovers the transaction hash, and generates display metadata.
Before sending, it also runs estimateGas and reserves a 20% gas limit buffer:
The balance check must use the maxFeePerGas or gasPrice returned by the network. The first contract deployment is usually more expensive than later publishes, and the cost of storing a full HTML or JSON grows noticeably with the byte count.
Why the homepage needs separate normalization
The homepage stores the complete HTML generated by Hexo, but the bottom of the page includes the build hash and build time, and the page also displays the previous publish's block height and transaction hash. Hashing the HTML directly would create a loop:
TEXT
构建时间变化 -> HTML 哈希变化 -> 再次上链
链上信息变化 -> HTML 再次变化 -> 再次上链
The solution is to remove two dynamic regions before computing contentHash:
The replacement logic should require each region to appear exactly once. Silently ignoring template structure changes is dangerous, because a theme change could let a dynamic field slip back into the hash and cause you to pay on every build.
Note that the contract actually stores the complete HTML from that particular build; normalization is only used to compute the content hash and deduplicate. Changing only the build time will not overwrite the old on-chain HTML — that's the intended behavior.
GitHub Actions dual-build workflow
The workflow listens only to the main branch and grants contents: write permission:
YAML
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
The full steps are as follows:
Install pinned versions of pnpm and Node.js.
Install dependencies with pnpm install --frozen-lockfile.
Update the article index in the README and run the tests.
Build Hexo the first time to produce the homepage HTML that needs to go on-chain.
Publish the homepage and articles to Polygon.
Write the block height, transaction hash, and contract address into temporary environment variables and .onchain/articles.json.
Build Hexo a second time so the page displays the on-chain information just obtained.
.onchain/articles.json only serves the second build of the same workflow run, so it should be added to .gitignore. The truly trusted data source is still Polygon; the JSON file is only a cache for generating pages.
Secrets and permissions configuration
In the repository's Settings -> Secrets and variables -> Actions, you need to configure:
Type
Name
Purpose
Secret
`POLYGON_PRIVATE_KEY`
Private key of the publishing wallet
Variable
`POLYGON_RPC_URL`
Optional Polygon mainnet RPC
POLYGON_PRIVATE_KEY is the wallet's 32-byte private key, usually represented as 64 hexadecimal characters. The script accepts it with or without the 0x prefix, but you must not enter a wallet address, a mnemonic name, or an API key.
The private key must be stored in a Secret — not in repository files, an Actions Variable, build logs, or front-end JavaScript.GITHUB_TOKEN is provided automatically by GitHub Actions and doesn't need to be created manually.
For the publishing wallet, I recommend:
Keep only enough POL to pay for a few publishes.
Don't share it with your daily wallet or main assets.
Regularly check the Actions logs for unusual transactions.
Don't expose the mainnet publish step in Pull Request workflows.
Verification before and after going on-chain
First verify the build and payload locally:
BASH
pnpm clean
pnpm build
pnpm test
You can also simulate only the contract compilation, address calculation, and article size:
When verifying, don't just look at the text on the page. At minimum, confirm the following on Polygonscan:
The network is Polygon PoS mainnet, with chain ID 137.
The transaction sender is the expected publishing wallet.
The transaction target is the expected contract address.
The transaction succeeded and recorded the correct block height.
The payloadHash returned by the contract matches the Keccak-256 of the actual payload.
Pitfalls I actually ran into
1. A Polygon testnet is not unlimited and free
The Amoy testnet is fine for validating the flow, but faucets usually limit how often and how much you can claim. The calldata and storage for a full HTML or article payload are expensive, and the first deployment plus first write can easily exceed the test tokens you can claim in a day.
When testing, shorten the payload first, verify 'deployment' and 'publishing' separately, and print the gas estimate before sending the transaction. When switching to mainnet, check the chain ID, RPC, block explorer URL, and gas token together — don't just swap the RPC URL.
2. Build time sends the homepage on-chain endlessly
As soon as the HTML contains the current time, Git commit, live block height, or a random value, the raw HTML hash becomes unstable. You must first define which fields represent content and which are only display metadata, then normalize strictly.
3. Unstable RPC causes intermittent CI failures
Public RPC endpoints may rate-limit, time out, or not support batch requests. The article publisher should disable unnecessary JSON-RPC batching, set an explicit network, and distinguish in its logs between 'RPC failure', 'insufficient balance', 'transaction reverted', and 'confirmation wait timeout'.
Don't resend unconditionally after a failure. First read the on-chain contentHash, because the previous transaction may have already been broadcast successfully and CI was just interrupted while waiting for the receipt.
A typical static blog solves the problem of 'how to publish', but does not fully solve the problem of 'how to prove': whether an article has been modified, when a particular version actually appeared, and whether the original content can still be verified after the site goes offline all depend on the site owner and the hosting platform.
So this is the approach: Hexo generates the site, GitHub Actions handles the automated build, Polygon PoS stores the homepage and article data, and GitHub Pages serves up a normal browsing experience.
Final architecture
The whole publishing process can be summarized as:
There are two independent contracts here:
The pages are still loaded from a CDN or GitHub Pages, so loading speed is no different from a normal Hexo blog. The blockchain provides a public, verifiable publication record that cannot be silently rewritten.
Setting up the environment
Locally you need Node.js, pnpm, Git, and a Polygon wallet:
Install dependencies and verify Hexo:
Production publishing uses the Polygon mainnet, whose chain ID is 137, and the gas token is POL. I recommend creating a separate wallet used only for publishing rather than using the wallet that holds your main assets.
Design the on-chain article format first
An article can't just stuff Hexo's generated HTML into the contract as-is. The HTML mixes in theme structure, build metadata, and styling details, so a small theme adjustment would make every article produce a new version.
I ended up using the following normalized JSON:
content is not ordinary Markdown; it is the result of the following processing:
Determinism matters a lot. The same article must yield the same byte sequence whether it is built on Windows or Linux. Otherwise, every GitHub Actions run could mistake it for new content and pay gas for nothing.
The article content hash is not computed directly over the compressed Base64; instead, it computes keccak256 over the semantic JSON that contains the normalized Markdown. This way, the hash represents the article's content itself rather than the output details of a particular compressor version.
A note on images
Local images can be read as real binary data and hashed, but remote images have a frequently overlooked problem: the build machine may not be able to download them reliably, and the remote may enforce hotlink protection, time out, or serve content that changes dynamically.
The current implementation computes an 'MD5 of the image URL string' for external images, which only identifies which address the article references; it cannot prove the content returned at that address, nor guarantee that the image will remain permanently available.
If you want strict, permanent image archiving, you can choose one of the following:
Therefore, 'the article's Markdown is on-chain' and 'all resources referenced by the article are permanently preserved' are two different things.
Designing the Solidity contract
The core structure of the article contract is not complicated:
When writing, it doesn't directly use the string slug; instead, it first computes:
The contract's publish function has to perform several checks:
I limit a single article to 24,576 bytes. The limit applies to the UTF-8 byte length of the final JSON, not the size of the Markdown file. gzip shrinks the body, but Base64 adds about a third back, so be sure to check the final payload before sending the transaction.
The contract stores two hashes at the same time:
What does 'permanent' actually mean?
The current structure overwrites publications[slugHash].payload on every update, so the contract's read interface can only return the latest version. Older versions still exist in the blockchain history, transaction calldata, and events, but they cannot be queried directly through the current mapping.
If you need every historical version to be directly readable from a contract method, it should be changed to:
The cost is continuously growing contract storage and higher gas. For a personal blog, keeping the latest readable version while relying on transaction history to prove older versions is a more practical trade-off.
Using CREATE2 to fix the contract address
CI can't depend on a deployment result saved locally, so I use CREATE2 to precompute the contract address:
initCode contains the contract bytecode and constructor arguments. As long as the deployer, salt, compiled output, and owner are exactly identical, the final address will be identical. The publish script first calls eth_getCode: if there is no code at the address, it deploys; if there is code, it reuses it directly.
The catch here is that changing the publishing wallet also changes the contract address, because owner is part of the constructor arguments. Upgrading the Solidity version, changing the optimizer settings, adjusting the contract code, or changing the salt will likewise produce a new address. Don't casually change these values without a migration plan.
How the publish script avoids duplicate transactions
The article publish script scans source/_posts and requires the filename to match the numeric slug:
The numeric slug must be a positive integer and must be unique. It serves both as part of the page's permanent link and as the logical primary key for the article on-chain. After publishing, don't move the article to a different slug, or the chain will treat it as a brand-new article.
For each article, the script first reads the on-chain record:
This means each CI run can scan all articles, but only newly added or actually modified articles will send a transaction. For unchanged articles, the script queries historical events by the publish block, recovers the transaction hash, and generates display metadata.
Before sending, it also runs estimateGas and reserves a 20% gas limit buffer:
The balance check must use the maxFeePerGas or gasPrice returned by the network. The first contract deployment is usually more expensive than later publishes, and the cost of storing a full HTML or JSON grows noticeably with the byte count.
Why the homepage needs separate normalization
The homepage stores the complete HTML generated by Hexo, but the bottom of the page includes the build hash and build time, and the page also displays the previous publish's block height and transaction hash. Hashing the HTML directly would create a loop:
The solution is to remove two dynamic regions before computing contentHash:
The replacement logic should require each region to appear exactly once. Silently ignoring template structure changes is dangerous, because a theme change could let a dynamic field slip back into the hash and cause you to pay on every build.
Note that the contract actually stores the complete HTML from that particular build; normalization is only used to compute the content hash and deduplicate. Changing only the build time will not overwrite the old on-chain HTML — that's the intended behavior.
GitHub Actions dual-build workflow
The workflow listens only to the main branch and grants contents: write permission:
The full steps are as follows:
The core commands can be written as:
.onchain/articles.json only serves the second build of the same workflow run, so it should be added to .gitignore. The truly trusted data source is still Polygon; the JSON file is only a cache for generating pages.
Secrets and permissions configuration
In the repository's Settings -> Secrets and variables -> Actions, you need to configure:
Type
Name
Purpose
Secret
`POLYGON_PRIVATE_KEY`
Private key of the publishing wallet
Variable
`POLYGON_RPC_URL`
Optional Polygon mainnet RPC
POLYGON_PRIVATE_KEY is the wallet's 32-byte private key, usually represented as 64 hexadecimal characters. The script accepts it with or without the 0x prefix, but you must not enter a wallet address, a mnemonic name, or an API key.
The private key must be stored in a Secret — not in repository files, an Actions Variable, build logs, or front-end JavaScript.GITHUB_TOKEN is provided automatically by GitHub Actions and doesn't need to be created manually.
For the publishing wallet, I recommend:
Verification before and after going on-chain
First verify the build and payload locally:
You can also simulate only the contract compilation, address calculation, and article size:
After publishing, you can read the article back from the contract and decompress it:
When verifying, don't just look at the text on the page. At minimum, confirm the following on Polygonscan:
Pitfalls I actually ran into
1. A Polygon testnet is not unlimited and free
The Amoy testnet is fine for validating the flow, but faucets usually limit how often and how much you can claim. The calldata and storage for a full HTML or article payload are expensive, and the first deployment plus first write can easily exceed the test tokens you can claim in a day.
When testing, shorten the payload first, verify 'deployment' and 'publishing' separately, and print the gas estimate before sending the transaction. When switching to mainnet, check the chain ID, RPC, block explorer URL, and gas token together — don't just swap the RPC URL.
2. Build time sends the homepage on-chain endlessly
As soon as the HTML contains the current time, Git commit, live block height, or a random value, the raw HTML hash becomes unstable. You must first define which fields represent content and which are only display metadata, then normalize strictly.
3. Unstable RPC causes intermittent CI failures
Public RPC endpoints may rate-limit, time out, or not support batch requests. The article publisher should disable unnecessary JSON-RPC batching, set an explicit network, and distinguish in its logs between 'RPC failure', 'insufficient balance', 'transaction reverted', and 'confirmation wait timeout'.
Don't resend unconditionally after a failure. First read the on-chain contentHash, because the previous transaction may have already been broadcast successfully and CI was just interrupted while waiting for the receipt.
References