62ab84c472
Introduce repo-metadata.toml as single source of truth for repo identity (org slug, maintainer email, canonical URLs) and add sync-metadata.yml workflow with a Python audit script that fails CI if any tracked file drifts back to pre-migration values. Bulk-replace across 24 tracked files: - kingchenc/wickra -> wickra-lib/wickra (URL segment) - kingchencp@gmail.com -> wickra.lib@gmail.com (maintainer email) - @kingchenc -> @wickra-lib (CODEOWNERS mention only) Person-name credits are preserved: LICENSE copyright holder, Cargo.toml authors handle, and CHANGELOG historical @kingchenc reference all remain unchanged. Crate / PyPI / npm package names also untouched. Merge this PR only after the kingchenc/wickra -> wickra-lib/wickra org transfer has happened on the GitHub side, otherwise all badges and repository links 404 until the transfer is performed.
498 lines
21 KiB
YAML
498 lines
21 KiB
YAML
name: Release
|
|
|
|
on:
|
|
push:
|
|
tags: ["v*"]
|
|
workflow_dispatch:
|
|
|
|
env:
|
|
CARGO_TERM_COLOR: always
|
|
|
|
jobs:
|
|
# --------------------------------------------------------------------------
|
|
# crates.io: three crates in dependency order
|
|
# --------------------------------------------------------------------------
|
|
cargo-publish:
|
|
name: Publish to crates.io
|
|
runs-on: ubuntu-latest
|
|
# The publish jobs run with long-lived registry tokens. Binding them to a
|
|
# protected GitHub environment lets the org require a reviewer to approve
|
|
# each release and restrict which tags/branches may deploy, so the secrets
|
|
# are not reachable from an arbitrary workflow run. The `release`
|
|
# environment and its protection rules are configured under repo
|
|
# Settings -> Environments.
|
|
environment: release
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
|
|
|
# Idempotent publishing: if the version is already on crates.io we
|
|
# treat that as success so re-runs of the workflow don't fail.
|
|
- name: Publish wickra-core (idempotent)
|
|
run: |
|
|
out=$(cargo publish -p wickra-core --token "$CARGO_REGISTRY_TOKEN" 2>&1) && echo "$out" \
|
|
|| (echo "$out" | grep -q "already uploaded\|already exists" && echo "skip: already published" \
|
|
|| (echo "$out"; exit 1))
|
|
env:
|
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
|
|
- name: Wait for crates.io index to refresh
|
|
run: sleep 45
|
|
|
|
- name: Publish wickra-data (idempotent)
|
|
run: |
|
|
out=$(cargo publish -p wickra-data --token "$CARGO_REGISTRY_TOKEN" 2>&1) && echo "$out" \
|
|
|| (echo "$out" | grep -q "already uploaded\|already exists" && echo "skip: already published" \
|
|
|| (echo "$out"; exit 1))
|
|
env:
|
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
|
|
- name: Wait for crates.io index to refresh
|
|
run: sleep 45
|
|
|
|
- name: Publish wickra (idempotent)
|
|
run: |
|
|
out=$(cargo publish -p wickra --token "$CARGO_REGISTRY_TOKEN" 2>&1) && echo "$out" \
|
|
|| (echo "$out" | grep -q "already uploaded\|already exists" && echo "skip: already published" \
|
|
|| (echo "$out"; exit 1))
|
|
env:
|
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
|
|
# Produce .crate files for the GitHub Release attachments. `cargo package`
|
|
# writes them to target/package/<name>-<version>.crate.
|
|
#
|
|
# No --allow-dirty: `actions/checkout` gives a clean tree and nothing
|
|
# above mutates it. No --no-verify: every crate was just published to
|
|
# crates.io in the steps above, so the verification build resolves its
|
|
# workspace dependencies from the registry and confirms each .crate
|
|
# actually builds before it is attached to the release.
|
|
- name: Build .crate files for release attachment
|
|
run: |
|
|
cargo package -p wickra-core
|
|
cargo package -p wickra-data
|
|
cargo package -p wickra
|
|
|
|
- name: Upload .crate files
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: crate-files
|
|
path: target/package/*.crate
|
|
|
|
# --------------------------------------------------------------------------
|
|
# PyPI: cross-platform wheels + sdist
|
|
# --------------------------------------------------------------------------
|
|
python-wheels:
|
|
name: Build wheels (${{ matrix.target }}/${{ matrix.manylinux }} on ${{ matrix.os }})
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
# glibc Linux (manylinux)
|
|
- { os: ubuntu-latest, target: x86_64, manylinux: auto }
|
|
- { os: ubuntu-latest, target: aarch64, manylinux: auto }
|
|
# musl Linux (Alpine and other musl distros)
|
|
- { os: ubuntu-latest, target: x86_64, manylinux: musllinux_1_2 }
|
|
- { os: ubuntu-latest, target: aarch64, manylinux: musllinux_1_2 }
|
|
# macOS
|
|
- { os: macos-latest, target: x86_64, manylinux: auto }
|
|
- { os: macos-latest, target: aarch64, manylinux: auto }
|
|
# Windows
|
|
- { os: windows-latest, target: x64, manylinux: auto }
|
|
- { os: windows-11-arm, target: aarch64, manylinux: auto }
|
|
runs-on: ${{ matrix.os }}
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
with:
|
|
python-version: "3.11"
|
|
- name: Sync root README into bindings/python so it ships with the wheel
|
|
shell: bash
|
|
run: cp README.md bindings/python/README.md
|
|
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
|
with:
|
|
working-directory: bindings/python
|
|
target: ${{ matrix.target }}
|
|
args: --release --strip --out dist
|
|
manylinux: ${{ matrix.manylinux }}
|
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
# Include manylinux in the name so the glibc and musl x86_64/aarch64
|
|
# builds do not collide on the same artifact name.
|
|
name: wheels-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux }}
|
|
path: bindings/python/dist/*
|
|
|
|
python-sdist:
|
|
name: Build Python sdist
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
- name: Sync root README into bindings/python so it ships in the sdist
|
|
run: cp README.md bindings/python/README.md
|
|
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
|
with:
|
|
working-directory: bindings/python
|
|
command: sdist
|
|
args: --out dist
|
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: wheels-sdist
|
|
path: bindings/python/dist/*
|
|
|
|
python-publish:
|
|
name: Publish to PyPI
|
|
needs: [python-wheels, python-sdist]
|
|
runs-on: ubuntu-latest
|
|
environment: release
|
|
steps:
|
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
path: dist
|
|
pattern: wheels-*
|
|
merge-multiple: true
|
|
|
|
- name: Upload to PyPI
|
|
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
|
with:
|
|
command: upload
|
|
args: --skip-existing dist/*
|
|
env:
|
|
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# npm: per-platform native binaries + main meta-package
|
|
# --------------------------------------------------------------------------
|
|
node-build:
|
|
name: Node build (${{ matrix.target }})
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- { host: ubuntu-latest, target: x86_64-unknown-linux-gnu }
|
|
- { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
|
|
- { host: macos-latest, target: x86_64-apple-darwin }
|
|
- { host: macos-latest, target: aarch64-apple-darwin }
|
|
- { host: windows-latest, target: x86_64-pc-windows-msvc }
|
|
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
|
|
runs-on: ${{ matrix.host }}
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: "20"
|
|
|
|
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
|
with:
|
|
targets: ${{ matrix.target }}
|
|
|
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
|
|
|
- name: Install Node deps
|
|
working-directory: bindings/node
|
|
run: npm install
|
|
|
|
- name: Build native module
|
|
working-directory: bindings/node
|
|
run: npx napi build --platform --release --target ${{ matrix.target }}
|
|
|
|
- name: Upload artifact
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: bindings-${{ matrix.target }}
|
|
path: bindings/node/wickra.*.node
|
|
if-no-files-found: error
|
|
|
|
node-publish:
|
|
name: Publish to npm
|
|
needs: node-build
|
|
runs-on: ubuntu-latest
|
|
environment: release
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: "20"
|
|
registry-url: "https://registry.npmjs.org"
|
|
|
|
- name: Install Node deps
|
|
working-directory: bindings/node
|
|
run: npm install
|
|
|
|
- name: Download all platform binaries
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
path: bindings/node/artifacts
|
|
pattern: bindings-*
|
|
|
|
# The npm/<platform>/package.json templates are committed to the
|
|
# repo (see bindings/node/npm/), so `napi artifacts` only needs to
|
|
# drop the freshly built .node binary into the matching directory.
|
|
- name: Move binaries into platform package layout
|
|
working-directory: bindings/node
|
|
run: npx napi artifacts --dir artifacts
|
|
|
|
# Publish each platform package individually. Skip versions that are
|
|
# already on npm. A first-attempt 403 from npm's spam filter is
|
|
# tolerated for a single 30-second retry — that historically clears
|
|
# rate-limit-driven false positives. Anything that still fails after
|
|
# the retry is a *real* failure (the platform binary will be missing
|
|
# from `optionalDependencies` and Windows-style installs will break,
|
|
# exactly the regression that produced audit finding R20) — fail the
|
|
# job loudly so the release does not silently land in a half-published
|
|
# state. Previously this loop swallowed the second-attempt failure with
|
|
# a `::warning::` and `return 0`; that mask is removed.
|
|
- name: Publish platform packages (idempotent)
|
|
working-directory: bindings/node
|
|
env:
|
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
run: |
|
|
set +e
|
|
version=$(node -p "require('./package.json').version")
|
|
fail=0
|
|
publish_dir() {
|
|
local dir=$1
|
|
local pkg=$(basename "$dir")
|
|
local pkgname="wickra-$pkg"
|
|
local existing
|
|
existing=$(npm view "$pkgname@$version" version 2>/dev/null)
|
|
if [ "$existing" = "$version" ]; then
|
|
echo "::notice::skip $pkgname@$version (already on npm)"
|
|
return 0
|
|
fi
|
|
echo "::group::publish $pkgname@$version"
|
|
# --ignore-scripts: a per-platform package must never run lifecycle
|
|
# scripts during publish (npm runs prepublishOnly/prepare/etc. from
|
|
# the package being published — a malicious or stray script would
|
|
# execute with the npm token in the environment).
|
|
(cd "$dir" && npm publish --access public --ignore-scripts)
|
|
local rc=$?
|
|
echo "::endgroup::"
|
|
if [ "$rc" -ne 0 ]; then
|
|
echo "::warning::first attempt of $pkgname failed (rc=$rc); retrying after 30s"
|
|
sleep 30
|
|
(cd "$dir" && npm publish --access public --ignore-scripts)
|
|
rc=$?
|
|
fi
|
|
if [ "$rc" -ne 0 ]; then
|
|
echo "::error::$pkgname could not be published — the release would land with a missing platform binary; failing the job."
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
for dir in npm/*/; do
|
|
publish_dir "$dir" || fail=1
|
|
done
|
|
exit $fail
|
|
|
|
- name: Sync root README into bindings/node so it ships with the npm tarball
|
|
# npm reads README.md from the package directory at publish time. Copy
|
|
# the canonical root README in just before the publish so every
|
|
# registry shows the same project page.
|
|
shell: bash
|
|
run: cp ../../README.md README.md
|
|
working-directory: bindings/node
|
|
|
|
- name: Publish main package to npm (idempotent)
|
|
working-directory: bindings/node
|
|
env:
|
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
shell: bash
|
|
run: |
|
|
# GitHub Actions defaults to `bash -e`. `npm view` on a not-yet-
|
|
# published package returns 1, which would kill the step before
|
|
# we got to publish. Force a graceful empty result instead.
|
|
set +e
|
|
version=$(node -p "require('./package.json').version")
|
|
existing=$(npm view "wickra@$version" version 2>/dev/null)
|
|
if [ "$existing" = "$version" ]; then
|
|
echo "::notice::skip wickra@$version (already on npm)"
|
|
exit 0
|
|
fi
|
|
# --ignore-scripts so any leftover prepublish hooks (which would
|
|
# otherwise try to republish the already-published platform
|
|
# subpackages) can't sabotage the main publish.
|
|
npm publish --access public --ignore-scripts
|
|
rc=$?
|
|
if [ "$rc" -ne 0 ]; then
|
|
echo "::warning::first attempt failed (rc=$rc); retrying after 30s"
|
|
sleep 30
|
|
npm publish --access public --ignore-scripts
|
|
rc=$?
|
|
fi
|
|
exit $rc
|
|
|
|
- name: Pack node tarballs for release attachment
|
|
working-directory: bindings/node
|
|
run: |
|
|
# Main package
|
|
npm pack --ignore-scripts
|
|
# Each per-platform package (the binaries were already moved in by
|
|
# napi artifacts). --ignore-scripts for the same reason as publish:
|
|
# packing must not execute lifecycle scripts from the packed dir.
|
|
for d in npm/*/; do
|
|
(cd "$d" && npm pack --ignore-scripts)
|
|
done
|
|
|
|
- name: Upload Node tarballs
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: node-tarballs
|
|
path: |
|
|
bindings/node/*.tgz
|
|
bindings/node/npm/*/*.tgz
|
|
|
|
# --------------------------------------------------------------------------
|
|
# WASM: wasm-pack build + npm publish (as `wickra-wasm`)
|
|
# --------------------------------------------------------------------------
|
|
wasm-publish:
|
|
name: Publish wickra-wasm to npm
|
|
runs-on: ubuntu-latest
|
|
environment: release
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: "20"
|
|
registry-url: "https://registry.npmjs.org"
|
|
|
|
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
|
with:
|
|
targets: wasm32-unknown-unknown
|
|
|
|
- name: Install wasm-pack (latest, via prebuilt binary)
|
|
# See the matching note in ci.yml: jetli's default installs an old
|
|
# 0.10.x wasm-pack whose build subcommand rejects --features.
|
|
uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5
|
|
with:
|
|
tool: wasm-pack
|
|
|
|
- name: Sync root README into bindings/wasm so wasm-pack ships it in pkg/
|
|
# wasm-pack copies the crate's README.md into the generated pkg/
|
|
# directory it then publishes. Refresh it from the canonical root
|
|
# README right before the build.
|
|
run: cp README.md bindings/wasm/README.md
|
|
|
|
- name: Build WASM package (bundler target)
|
|
run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook
|
|
|
|
- name: Enrich generated package.json with author + repo metadata
|
|
working-directory: bindings/wasm/pkg
|
|
run: |
|
|
node -e "
|
|
const fs = require('fs');
|
|
const pkg = JSON.parse(fs.readFileSync('package.json'));
|
|
pkg.author = 'kingchenc <wickra.lib@gmail.com>';
|
|
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
|
|
pkg.homepage = 'https://github.com/wickra-lib/wickra';
|
|
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
|
|
pkg.license = 'PolyForm-Noncommercial-1.0.0';
|
|
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
|
"
|
|
|
|
- name: Pack wickra-wasm for release attachment
|
|
working-directory: bindings/wasm/pkg
|
|
run: npm pack
|
|
|
|
- name: Upload WASM tarball
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: wasm-tarball
|
|
path: bindings/wasm/pkg/wickra-wasm-*.tgz
|
|
|
|
- name: Publish wickra-wasm to npm (idempotent)
|
|
working-directory: bindings/wasm/pkg
|
|
run: |
|
|
out=$(npm publish --access public 2>&1) && echo "$out" \
|
|
|| (echo "$out" | grep -q "You cannot publish over" && echo "skip: version already on npm" \
|
|
|| (echo "$out"; exit 1))
|
|
env:
|
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# GitHub Release: attach every built artefact to the tag's release page.
|
|
# --------------------------------------------------------------------------
|
|
github-release:
|
|
name: Attach assets to the GitHub Release
|
|
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Resolve target tag
|
|
id: tag
|
|
run: |
|
|
if [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
|
tag="${{ github.ref_name }}"
|
|
else
|
|
# workflow_dispatch / non-tag push: attach to the latest v* tag.
|
|
tag=$(git tag --list 'v*' --sort=-v:refname | head -n1)
|
|
fi
|
|
if [ -z "$tag" ]; then
|
|
echo "::error::no v* tag found to attach assets to"
|
|
exit 1
|
|
fi
|
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::attaching assets to release $tag"
|
|
|
|
- name: Download all build artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
path: artifacts
|
|
|
|
- name: Stage release assets
|
|
run: |
|
|
set -e
|
|
mkdir -p release-assets
|
|
# Python wheels + sdist (5 wheel artifacts + 1 sdist artifact).
|
|
find artifacts -type f -name "*.whl" -exec cp {} release-assets/ \;
|
|
find artifacts -type f -name "*.tar.gz" -exec cp {} release-assets/ \;
|
|
# Native Node binaries (one per platform).
|
|
find artifacts -type f -name "*.node" -exec cp {} release-assets/ \;
|
|
# Node npm-pack tarballs (main + per-platform).
|
|
find artifacts -type f -name "wickra-*.tgz" -exec cp {} release-assets/ \;
|
|
# Cargo .crate files (one per workspace member).
|
|
find artifacts -type f -name "*.crate" -exec cp {} release-assets/ \;
|
|
ls -lh release-assets/
|
|
echo "asset-count=$(ls release-assets/ | wc -l)"
|
|
|
|
- name: Create / update GitHub Release with assets
|
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
|
with:
|
|
tag_name: ${{ steps.tag.outputs.tag }}
|
|
name: Wickra ${{ steps.tag.outputs.tag }}
|
|
files: release-assets/*
|
|
generate_release_notes: true
|
|
fail_on_unmatched_files: false
|
|
body: |
|
|
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
|
|
|
|
### Install
|
|
|
|
```bash
|
|
cargo add wickra
|
|
pip install wickra
|
|
npm install wickra
|
|
npm install wickra-wasm
|
|
```
|
|
|
|
### Attached assets
|
|
|
|
Pre-built artefacts for every supported platform — the same files that
|
|
were uploaded to crates.io, PyPI, and npm by this workflow run.
|
|
|
|
- `*.whl` / `wickra-*.tar.gz` — Python wheels + sdist (5 platforms, ABI3 ≥ 3.9)
|
|
- `wickra.*.node` — native Node bindings (linux-x64-gnu, darwin-x64,
|
|
darwin-arm64, win32-x64-msvc)
|
|
- `wickra-*.tgz` — npm-pack tarballs (main package + per-platform subpackages + WASM)
|
|
- `*.crate` — cargo source crates (wickra-core, wickra-data, wickra)
|
|
|
|
### Auto-generated changelog
|
|
|
|
See below; GitHub computes it from the commits since the previous tag. |