diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..49a38517 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# The publish workflows read each line of this list into an artifact path, so a CRLF +# checkout would look for out/Name\r.sol/Name\r.json and abort the release. Pin it to +# LF regardless of the platform's autocrlf setting. The pre-commit hook rejects a CR +# on the way in; this covers the way out. +.github/abi-contracts.txt text eol=lf diff --git a/.github/abi-contracts.txt b/.github/abi-contracts.txt new file mode 100644 index 00000000..e9e20feb --- /dev/null +++ b/.github/abi-contracts.txt @@ -0,0 +1,46 @@ +# Contracts and interfaces whose ABIs ship in the release artifact. +# +# Read by .github/workflows/publish-release.yml and publish-prerelease.yml. The +# release surface is a deliberate decision, so it is listed explicitly here rather +# than globbed from contracts/**, which would make it a side effect of the directory +# layout. Listing it once keeps the two workflows from drifting apart. +# +# One name per line, matching the artifact path out/.sol/.json. Blank +# lines and # comments are ignored. A name with no build artifact fails the release. +# +# Adding a contract? Add it and its interface here, or its ABI never reaches +# consumers. See the protocol-registry section of CONTRIBUTING.md. + +StoreFactory +LabelStore +UserStore +DotnsRegistrar +DotnsReverseResolver +DotnsRegistry +DotnsContentResolver +DotnsResolver +PopRules +DotnsRegistrarController +DotnsProtocolRegistry +DotnsNameEscrow +DotnsPopController +DotnsPopResolver +DotnsRoleManager +RootGatewayDispatcher + +IStoreFactory +ILabelStore +IUserStore +IDotnsRegistrar +IDotnsRegistrarController +IDotnsRegistry +IDotnsReverseResolver +IDotnsContentResolver +IDotnsResolver +IPopRules +IDotnsProtocolRegistry +IDotnsNameEscrow +IDotnsPopController +IDotnsPopResolver +IDotnsController +IDotnsRoleManager diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 0bc48429..9a4ee361 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -4,14 +4,77 @@ on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+-*" + workflow_dispatch: + inputs: + version: + description: "Pre-release version, e.g. v0.5.5-rc1. The tag is created from the selected branch." + required: true + type: string permissions: contents: write +# Two runs for the same version would race to attach assets to the same draft. On a +# dispatch `github.ref_name` is the branch, so key on the requested version instead. +concurrency: + group: ${{ github.workflow }}-${{ inputs.version || github.ref_name }} + cancel-in-progress: false + jobs: beta-release: runs-on: ubuntu-latest steps: + # On workflow_dispatch the tag does not exist yet; the release step creates it + # from the branch this run was started on. Read through an env var rather than + # interpolating the input into the script. + - name: Resolve release tag + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + TAG="$INPUT_VERSION" + else + TAG="$GITHUB_REF_NAME" + fi + # The suffix is restricted to characters GitHub keeps verbatim in an asset + # name; a space, for instance, is rewritten to a dot and would fail the + # asset check after a full build. + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.]+$ ]]; then + echo "::error::Pre-release version must look like v1.2.3-rc1, got '$TAG'." + exit 1 + fi + # A release created for an existing tag is cut at that tag's commit: GitHub + # ignores target_commitish when the tag is already there. The ABIs would come + # from this branch while the release pointed somewhere else. + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] \ + && gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" >/dev/null 2>&1; then + echo "::error::Tag $TAG already exists; use a different version." + exit 1 + fi + echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV" + echo "Releasing $TAG from $GITHUB_REF_NAME." + + - name: Reject a pre-published release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="$RELEASE_TAG" + if state=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq '.isDraft' 2>"$RUNNER_TEMP/gh-err.txt"); then + if [ "$state" = "false" ]; then + echo "::error::Pre-release $TAG is already published; use a different version." + exit 1 + fi + echo "$TAG exists as a draft; assets can still be attached." + elif grep -qi "release not found" "$RUNNER_TEMP/gh-err.txt"; then + echo "No release for $TAG yet." + else + echo "::error::Could not read the state of release $TAG; refusing to continue." + cat "$RUNNER_TEMP/gh-err.txt" + exit 1 + fi + - uses: actions/checkout@v4 with: submodules: recursive @@ -50,61 +113,36 @@ jobs: run: | mkdir -p release/abis - contracts=( - "StoreFactory" - "LabelStore" - "UserStore" - "DotnsRegistrar" - "DotnsReverseResolver" - "DotnsRegistry" - "DotnsContentResolver" - "DotnsResolver" - "PopRules" - "DotnsRegistrarController" - "DotnsProtocolRegistry" - "DotnsNameEscrow" - "DotnsPopController" - "DotnsPopResolver" - "DotnsRoleManager" - "RootGatewayDispatcher" - "IStoreFactory" - "ILabelStore" - "IUserStore" - "IDotnsRegistrar" - "IDotnsRegistrarController" - "IDotnsRegistry" - "IDotnsReverseResolver" - "IDotnsContentResolver" - "IDotnsResolver" - "IPopRules" - "IDotnsProtocolRegistry" - "IDotnsNameEscrow" - "IDotnsPopController" - "IDotnsPopResolver" - "IDotnsController" - "IDotnsRoleManager" - ) - - for name in "${contracts[@]}"; do + # read trims stray spaces and tabs on its own. A carriage return, from a list + # saved with CRLF line endings, it does not: that would end up inside the + # artifact path below, so strip it explicitly. + while read -r name || [ -n "$name" ]; do + name="${name%$'\r'}" + case "$name" in '' | '#'*) continue ;; esac abi_file="out/${name}.sol/${name}.json" - if [ -f "$abi_file" ]; then - jq '.abi' "$abi_file" > "release/abis/${name}.json" - echo "Extracted ${name}" - else - echo "Error: ${abi_file} not found" + if [ ! -f "$abi_file" ]; then + echo "::error::${abi_file} not found; check .github/abi-contracts.txt" exit 1 fi - done + jq '.abi' "$abi_file" > "release/abis/${name}.json" + echo "Extracted ${name}" + done < .github/abi-contracts.txt + + # Record what the build actually produced, so the post-upload check compares + # against it rather than against a second hand-maintained list. + ls -1 release/abis | sort > release/expected-assets.txt + echo "Extracted $(wc -l < release/expected-assets.txt) ABIs" - name: Package pre-release artifacts run: | - TAG="${GITHUB_REF_NAME}" + TAG="$RELEASE_TAG" cd release zip -r "../dotns-abis-${TAG}.zip" abis/ - name: Generate release body run: | - TAG="${GITHUB_REF_NAME}" + TAG="$RELEASE_TAG" + ASSET_BASE="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$TAG" cat > release-body.md << 'ENDOFBODY' ## DotNS ABI Package (Pre-release) @@ -118,14 +156,14 @@ jobs: for f in release/abis/*.json; do name=$(basename "$f" .json) - echo " | \`${name}\` | [${name}.json](dotns-abis-${TAG}.zip) |" >> release-body.md + echo " | \`${name}\` | [${name}.json]($ASSET_BASE/${name}.json) |" >> release-body.md done cat >> release-body.md << 'ENDOFBODY' ### Download - - **All ABIs (zip):** `dotns-abis-${TAG}.zip` + - **All ABIs (zip):** [dotns-abis-${TAG}.zip](${ASSET_BASE}/dotns-abis-${TAG}.zip) - **Individual ABIs:** Each contract ABI is also attached as a separate artifact ### Usage @@ -137,10 +175,17 @@ jobs: ENDOFBODY sed -i 's/^ //' release-body.md + # The heredocs above are quoted so the ```ts fence is not treated as command + # substitution, which also leaves ${TAG} unexpanded. Substitute it here. + sed -i "s|\${ASSET_BASE}|$ASSET_BASE|g; s|\${TAG}|$TAG|g" release-body.md - - name: Create pre-release with artifacts + - name: Create draft pre-release with artifacts uses: softprops/action-gh-release@v2 with: + # Explicit because on workflow_dispatch there is no tag to infer; the action + # creates it at this run's commit. + tag_name: ${{ env.RELEASE_TAG }} + target_commitish: ${{ github.sha }} files: | dotns-abis-*.zip release/abis/*.json @@ -148,3 +193,27 @@ jobs: draft: true prerelease: true generate_release_notes: true + + - name: Verify draft assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -o pipefail + TAG="$RELEASE_TAG" + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \ + --jq '.assets[].name' | sort > "$RUNNER_TEMP/actual-assets.txt" + { cat release/expected-assets.txt; echo "dotns-abis-${TAG}.zip"; } \ + | sort > "$RUNNER_TEMP/wanted-assets.txt" + if ! diff -u "$RUNNER_TEMP/wanted-assets.txt" "$RUNNER_TEMP/actual-assets.txt"; then + echo "::error::Draft pre-release $TAG does not match the expected asset set; delete the draft and re-run." + exit 1 + fi + echo "Verified $(wc -l < "$RUNNER_TEMP/wanted-assets.txt") assets on draft $TAG." + + - name: Publish pre-release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="$RELEASE_TAG" + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false + echo "Published $TAG with its complete asset set." diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index fc279d58..fcf830ba 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -4,14 +4,74 @@ on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + inputs: + version: + description: "Version to release, e.g. v0.5.5. The tag is created from the selected branch." + required: true + type: string permissions: contents: write +# Two runs for the same version would race to attach assets to the same draft. On a +# dispatch `github.ref_name` is the branch, so key on the requested version instead. +concurrency: + group: ${{ github.workflow }}-${{ inputs.version || github.ref_name }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest steps: + # On workflow_dispatch the tag does not exist yet; the release step creates it + # from the branch this run was started on. Read through an env var rather than + # interpolating the input into the script. + - name: Resolve release tag + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + TAG="$INPUT_VERSION" + else + TAG="$GITHUB_REF_NAME" + fi + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version must look like v1.2.3, got '$TAG'." + exit 1 + fi + # A release created for an existing tag is cut at that tag's commit: GitHub + # ignores target_commitish when the tag is already there. The ABIs would come + # from this branch while the release pointed somewhere else. + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] \ + && gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" >/dev/null 2>&1; then + echo "::error::Tag $TAG already exists; use a different version." + exit 1 + fi + echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV" + echo "Releasing $TAG from $GITHUB_REF_NAME." + + - name: Reject a pre-published release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="$RELEASE_TAG" + if state=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq '.isDraft' 2>"$RUNNER_TEMP/gh-err.txt"); then + if [ "$state" = "false" ]; then + echo "::error::Release $TAG is already published; use a different version." + exit 1 + fi + echo "$TAG exists as a draft; assets can still be attached." + elif grep -qi "release not found" "$RUNNER_TEMP/gh-err.txt"; then + echo "No release for $TAG yet." + else + echo "::error::Could not read the state of release $TAG; refusing to continue." + cat "$RUNNER_TEMP/gh-err.txt" + exit 1 + fi + - uses: actions/checkout@v4 with: submodules: recursive @@ -50,61 +110,36 @@ jobs: run: | mkdir -p release/abis - contracts=( - "StoreFactory" - "LabelStore" - "UserStore" - "DotnsRegistrar" - "DotnsReverseResolver" - "DotnsRegistry" - "DotnsContentResolver" - "DotnsResolver" - "PopRules" - "DotnsRegistrarController" - "DotnsProtocolRegistry" - "DotnsNameEscrow" - "DotnsPopController" - "DotnsPopResolver" - "DotnsRoleManager" - "RootGatewayDispatcher" - "IStoreFactory" - "ILabelStore" - "IUserStore" - "IDotnsRegistrar" - "IDotnsRegistrarController" - "IDotnsRegistry" - "IDotnsReverseResolver" - "IDotnsContentResolver" - "IDotnsResolver" - "IPopRules" - "IDotnsProtocolRegistry" - "IDotnsNameEscrow" - "IDotnsPopController" - "IDotnsPopResolver" - "IDotnsController" - "IDotnsRoleManager" - ) - - for name in "${contracts[@]}"; do + # read trims stray spaces and tabs on its own. A carriage return, from a list + # saved with CRLF line endings, it does not: that would end up inside the + # artifact path below, so strip it explicitly. + while read -r name || [ -n "$name" ]; do + name="${name%$'\r'}" + case "$name" in '' | '#'*) continue ;; esac abi_file="out/${name}.sol/${name}.json" - if [ -f "$abi_file" ]; then - jq '.abi' "$abi_file" > "release/abis/${name}.json" - echo "Extracted ${name}" - else - echo "Error: ${abi_file} not found" + if [ ! -f "$abi_file" ]; then + echo "::error::${abi_file} not found; check .github/abi-contracts.txt" exit 1 fi - done + jq '.abi' "$abi_file" > "release/abis/${name}.json" + echo "Extracted ${name}" + done < .github/abi-contracts.txt + + # Record what the build actually produced, so the post-upload check compares + # against it rather than against a second hand-maintained list. + ls -1 release/abis | sort > release/expected-assets.txt + echo "Extracted $(wc -l < release/expected-assets.txt) ABIs" - name: Package release artifacts run: | - TAG="${GITHUB_REF_NAME}" + TAG="$RELEASE_TAG" cd release zip -r "../dotns-abis-${TAG}.zip" abis/ - name: Generate release body run: | - TAG="${GITHUB_REF_NAME}" + TAG="$RELEASE_TAG" + ASSET_BASE="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$TAG" cat > release-body.md << 'ENDOFBODY' ## DotNS ABI Package @@ -116,14 +151,14 @@ jobs: for f in release/abis/*.json; do name=$(basename "$f" .json) - echo " | \`${name}\` | [${name}.json](dotns-abis-${TAG}.zip) |" >> release-body.md + echo " | \`${name}\` | [${name}.json]($ASSET_BASE/${name}.json) |" >> release-body.md done cat >> release-body.md << 'ENDOFBODY' ### Download - - **All ABIs (zip):** `dotns-abis-${TAG}.zip` + - **All ABIs (zip):** [dotns-abis-${TAG}.zip](${ASSET_BASE}/dotns-abis-${TAG}.zip) - **Individual ABIs:** Each contract ABI is also attached as a separate artifact ### Usage @@ -135,10 +170,17 @@ jobs: ENDOFBODY sed -i 's/^ //' release-body.md + # The heredocs above are quoted so the ```ts fence is not treated as command + # substitution, which also leaves ${TAG} unexpanded. Substitute it here. + sed -i "s|\${ASSET_BASE}|$ASSET_BASE|g; s|\${TAG}|$TAG|g" release-body.md - - name: Create release with artifacts + - name: Create draft release with artifacts uses: softprops/action-gh-release@v2 with: + # Explicit because on workflow_dispatch there is no tag to infer; the action + # creates it at this run's commit. + tag_name: ${{ env.RELEASE_TAG }} + target_commitish: ${{ github.sha }} files: | dotns-abis-*.zip release/abis/*.json @@ -146,3 +188,27 @@ jobs: draft: true prerelease: false generate_release_notes: true + + - name: Verify draft assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -o pipefail + TAG="$RELEASE_TAG" + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \ + --jq '.assets[].name' | sort > "$RUNNER_TEMP/actual-assets.txt" + { cat release/expected-assets.txt; echo "dotns-abis-${TAG}.zip"; } \ + | sort > "$RUNNER_TEMP/wanted-assets.txt" + if ! diff -u "$RUNNER_TEMP/wanted-assets.txt" "$RUNNER_TEMP/actual-assets.txt"; then + echo "::error::Draft release $TAG does not match the expected asset set; delete the draft and re-run." + exit 1 + fi + echo "Verified $(wc -l < "$RUNNER_TEMP/wanted-assets.txt") assets on draft $TAG." + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="$RELEASE_TAG" + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false + echo "Published $TAG with its complete asset set." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc363fef..47bfc8e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,7 +127,7 @@ Example query paths. Each row starts from a small set of known contracts; every Any new contract address that other contracts need to read must be looked up through `DotnsProtocolRegistry` at the point of use. Do not hardcode it in a constructor, store it in an `immutable`, or expose a one-off `setX(address)` setter. The protocol registry is the only address a contract may hold directly; everything else is fetched on demand so rotation is a single `protocolRegistry.set(KEY, newAddress)` call with no upgrade. -If you are adding a new contract category, add a `bytes32` key for it in `DotnsConstants.sol` and wire it up in `WireDeployments.s.sol`. Read it the same way every existing contract does. +If you are adding a new contract category, add a `bytes32` key for it in `DotnsConstants.sol`, wire it up in `WireDeployments.s.sol`, and list the contract and its interface in `.github/abi-contracts.txt` so their ABIs ship in the release artifact. Read it the same way every existing contract does. Bad — the registrar address is frozen at construction, so rotating it needs an upgrade: diff --git a/README.md b/README.md index b657d632..dbc770b2 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,18 @@ DotNS is a naming system for Polkadot. An account can register a .dot name, rece Current network addresses and deployment notes are listed in [DEPLOYMENTS.md](./DEPLOYMENTS.md). +### Cutting a release + +A release publishes the contract ABIs as GitHub release assets. It does not deploy anything; deploying contracts to a network is a separate process, described in [DEPLOYMENTS.md](./DEPLOYMENTS.md). + +Run **Publish Release Package** from the Actions tab, pick the branch to release from, and enter the version (`v0.5.5`). The workflow does the rest: it builds, tests, extracts the ABIs listed in [.github/abi-contracts.txt](./.github/abi-contracts.txt), creates the release as a draft with every asset attached, verifies the set against what the build produced, and only then publishes. Pushing a matching tag runs the same workflow, so `git tag v0.5.5 && git push origin v0.5.5` remains equivalent. + +Pre-releases use **Publish Beta Package** with a suffixed version, `v0.5.5-rc1`. The version is the release identity; the `version` field in `package.json` is unrelated and nothing reads it. + +Do not create releases through the GitHub UI's release form, or with `gh release create`. Both publish immediately, and because this repository has immutable releases enabled, a published release can no longer accept assets: only its title and notes stay editable. A release made that way carries no ABIs at all. The workflow rejects an already-published version before building, so the mistake fails in seconds rather than silently shipping an empty release. + +If a run fails partway, re-run it from the Actions tab; the draft is updated rather than duplicated. One case needs a manual step: the upload replaces an asset of the same name but never removes others, so if the contract list changed since the failed run, the draft still carries the assets it no longer expects and the verification step will keep refusing to publish. Delete the draft and re-run. If the version has already been published, use a different one, since its assets cannot be changed. + ## Economics dotNS uses a single tunable constant, written **D** throughout the protocol. D is the starting price used by PopRules and equals ten DOT at launch; governance can adjust it under the same gate as the upgrade authority. D is the only money quantity the protocol charges; everything else is a composition of D with zero. diff --git a/scripts/shell/pre-commit.sh b/scripts/shell/pre-commit.sh index f565d43f..64ce0db6 100755 --- a/scripts/shell/pre-commit.sh +++ b/scripts/shell/pre-commit.sh @@ -116,6 +116,15 @@ validate_git_config_file() { run_validation "$file" "git-config validation" git config --file "$file" --list } +validate_abi_contracts() { + local file="$1" + + # Each line becomes part of an artifact path in the publish workflows, so a + # carriage return from a CRLF save turns into out/Name\r.sol/Name\r.json and + # aborts the release. Reject it here instead. + run_validation "$file" "line-ending validation" awk '/\r/ { exit 1 }' "$file" +} + echo "pre-commit: validating repository files" while IFS= read -r -d '' file; do [ -f "$file" ] || continue @@ -148,6 +157,9 @@ while IFS= read -r -d '' file; do .gitmodules) validate_git_config_file "$file" ;; + .github/abi-contracts.txt) + validate_abi_contracts "$file" + ;; esac done < <(git ls-files -z)