diff --git a/.github/scripts/build-mcpb.sh b/.github/scripts/build-mcpb.sh index 70c5736..c1559b8 100755 --- a/.github/scripts/build-mcpb.sh +++ b/.github/scripts/build-mcpb.sh @@ -2,7 +2,7 @@ # Build the Claude Desktop bundle (.mcpb) for one imcp2-local release. # # .github/scripts/build-mcpb.sh -# e.g. .github/scripts/build-mcpb.sh imcp2-local-v0.5.0 dist-mcpb +# e.g. .github/scripts/build-mcpb.sh v0.6.0 dist-mcpb # # The bundle is assembled from the release's own published archives, so it # carries exactly the binaries that release attests, each checked against the @@ -37,10 +37,10 @@ set -euo pipefail tag="${1:?usage: build-mcpb.sh }" out="${2:?usage: build-mcpb.sh }" case "$tag" in - imcp2-local-v*) ;; - *) echo "not an imcp2-local release tag: $tag" >&2; exit 2 ;; + v[0-9]*) ;; + *) echo "not a vX.Y.Z release tag: $tag" >&2; exit 2 ;; esac -version="${tag#imcp2-local-v}" +version="${tag#v}" base="https://github.com/dfinity/imcp2/releases/download/$tag" repo_root="$(cd "$(dirname "$0")/../.." && pwd)" lipo="${LIPO:-lipo}" @@ -74,7 +74,7 @@ fi # refuses even a genuinely attested archive from an older release. verify_provenance() { gh attestation verify "$1" --repo dfinity/imcp2 \ - --signer-workflow dfinity/imcp2/.github/workflows/imcp2-local-release.yml \ + --signer-workflow dfinity/imcp2/.github/workflows/v-release.yml \ --source-ref "refs/tags/$tag" --deny-self-hosted-runners >/dev/null } diff --git a/.github/workflows/deploy-candidate.yml b/.github/workflows/deploy-candidate.yml new file mode 100644 index 0000000..87f41ce --- /dev/null +++ b/.github/workflows/deploy-candidate.yml @@ -0,0 +1,261 @@ +# Deploy a release candidate to staging. +# +# Staging is the one host this repository deploys, and it moves only when someone +# cuts a candidate: an `rc-X.Y.Z-N` tag on a commit of `main` whose manifests +# already say X.Y.Z. A merge to `main` deploys nothing, so what is under test never +# changes underneath the tester. Production is not deployed from here — it embeds +# the crate that the matching `vX.Y.Z` tag publishes (publish-crate.yml) — so the +# flow is candidate, test on staging, promote. See deploy/native/README.md. +# +# Cutting a candidate (the version bump has already landed on main): +# git tag rc-0.6.0-1 +# git push origin rc-0.6.0-1 +# A failed candidate is followed by a fix on main and rc-0.6.0-2. Nothing is +# bumped after a candidate, so the commit that is promoted is the one tested here. +# +# Rolling back, or redeploying: run this workflow from the Actions tab with an +# earlier rc-* tag (or a full commit SHA) as `ref`. +# +# Required repository secrets (Settings -> Secrets and variables -> Actions). A job +# calling a reusable workflow with `uses:` cannot declare an `environment:`, so the +# secrets passed below resolve at repository and organization scope only. The +# converse is the trap: deploy-native.yml's ship job does set `environment:`, so a +# secret defined on the `staging` environment resolves there and OVERRIDES what is +# passed here. Define each secret in exactly one place. +# DEPLOY_SSH_KEY private SSH key (PEM/OpenSSH) for a sudo-capable user on the host +# DEPLOY_HOST user@host, the host's PRIVATE address: the ship runner reaches +# it over the VPN (see the ship_runs_on comment below) +# DEPLOY_DOMAIN public FQDN served over HTTPS, e.g. mcp.example.com +# DEPLOY_ACME_EMAIL email for Let's Encrypt / ACME +# Optional: +# DEPLOY_KNOWN_HOSTS host public keys (output of `ssh-keyscan `). If unset, +# the host key is fetched at run time via ssh-keyscan (TOFU). +name: Deploy candidate + +on: + push: + tags: ['rc-*'] + # Manual re-run, and the rollback path: pass an earlier candidate tag as `ref`. + workflow_dispatch: + inputs: + ref: + description: >- + rc-* tag (e.g. rc-0.6.0-1, or the fully-qualified refs/tags/rc-0.6.0-1), + or a full 40-character commit SHA. + required: true + type: string + +# Never run two deploys at once; let an in-flight deploy finish rather than cancel it +# half-way through replacing the binary on the host. +concurrency: + group: deploy-staging + cancel-in-progress: false + +jobs: + # An `rc-*` tag push is constrained by the trigger. workflow_dispatch is not: its + # `ref` is free text, so `main` would deploy whatever that branch points at right + # now, which is the property this workflow exists to prevent. Accept only refs + # that cannot move: an rc-* tag, or a full 40-character commit SHA for the case + # where the commit to roll back to never had a tag cut for it. + validate-ref: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + outputs: + # The accepted ref in unambiguous form, consumed by deploy below. Empty when + # this job is skipped (tag push), which is what the fallback there handles. + ref: ${{ steps.normalize.outputs.ref }} + steps: + - name: Reject refs that can move + id: normalize + env: + REF: ${{ inputs.ref }} + run: | + set -euo pipefail + + # Constrain the entire input to one line of refname characters BEFORE + # looking at its shape. This step writes an input-derived value to + # $GITHUB_OUTPUT, and the dispatch input is free text that the REST API + # will happily accept newlines in. A newline turns the write below into + # multiple key=value lines, a second `ref=` line wins, and the deploy + # checks out whatever was appended while the run's inputs still show the + # innocuous value. Anchors must bind the whole string, so this uses bash + # [[ =~ ]] rather than grep, which is line-oriented. + # + # $REF is deliberately not echoed here: it has not been vetted yet, and + # stdout is parsed for ::workflow commands::. + if [[ ! "$REF" =~ ^[A-Za-z0-9._/-]+$ ]]; then + echo "::error::Refusing the dispatch ref: it must be a single line of [A-Za-z0-9._/-] characters. Pass an rc-* tag or a full 40-character commit SHA." + exit 1 + fi + + # Strip a fully-qualified tag prefix so refs/tags/rc-X is treated the same + # as rc-X. Only refs/tags/ is stripped: refs/heads/rc-X must stay + # rejected, and does, because after no stripping it no longer starts + # with "rc-". + ref="${REF#refs/tags/}" + + if [[ "$ref" =~ ^rc-[A-Za-z0-9._/-]+$ ]]; then + # git's own refname grammar catches what a character class cannot. + if ! git check-ref-format "refs/tags/$ref"; then + echo "::error::Refusing to deploy '$REF': not a valid git tag name." + exit 1 + fi + # Emit the tag fully qualified: a bare rc-X is ambiguous if a *branch* + # of that name also exists, and refs/tags/rc-X can only resolve to the + # tag, failing outright if no such tag exists. + echo "ref=refs/tags/$ref" >> "$GITHUB_OUTPUT" + elif [[ "$ref" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "ref=$ref" >> "$GITHUB_OUTPUT" + else + echo "::error::Refusing to deploy '$REF': it is neither an rc-* tag (bare or refs/tags/-qualified) nor a full 40-character commit SHA. Branches and abbreviated SHAs can move or become ambiguous, so the deployed revision would not be reproducible." + exit 1 + fi + echo "accepted '$REF'" + + # A candidate names the version it is a candidate for, and the manifests must + # already say so: the vX.Y.Z tag that promotes it is checked against every + # Cargo.toml by publish-crate.yml and against imcp2-local's by dist, and neither + # can be satisfied after the fact without changing the commit that was tested. + # The commit must also be on main, like the release tag's. + verify: + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + # Third-party actions are pinned to a commit SHA (supply-chain hardening); + # the trailing comment records the human-readable version. + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # Full history: the ancestry check below needs the commits between this + # tag and main, which the default shallow clone does not fetch. + fetch-depth: 0 + + - name: Check the tag names the version in every Cargo.toml + run: | + set -euo pipefail + if [[ ! "$GITHUB_REF_NAME" =~ ^rc-([0-9]+\.[0-9]+\.[0-9]+)-([0-9]+)$ ]]; then + echo "::error::${GITHUB_REF_NAME} is not an rc-X.Y.Z-N tag" >&2 + exit 1 + fi + version="${BASH_REMATCH[1]}" + for pkg in imcp2-core imcp2 imcp2-local; do + manifest="$(cargo metadata --format-version 1 --no-deps | + jq -er --arg pkg "$pkg" '.packages[] | select(.name == $pkg) | .version')" + echo "candidate=$version $pkg=$manifest" + if [ "$version" != "$manifest" ]; then + echo "::error::${GITHUB_REF_NAME} is a candidate for ${version} but ${pkg}'s Cargo.toml says ${manifest}; land the version bump on main first" >&2 + exit 1 + fi + done + + - name: Check the tag is on main + run: | + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then + echo "::error::${GITHUB_REF_NAME} points at a commit that is not on main" >&2 + exit 1 + fi + + deploy: + needs: [validate-ref, verify] + # One of the two is skipped on every path (validate-ref on a tag push, verify on + # a dispatch), and a skipped dependency would skip this job too under the + # default `success()` gate. Gate on "nothing failed" instead. + if: ${{ !failure() && !cancelled() }} + uses: ./.github/workflows/deploy-native.yml + with: + environment: staging + # A bare scale-set name, not a label list: the org's self-hosted capacity is + # ARC-managed pools, and a runner scale set is selected by its name alone, + # carrying none of the default labels classic runners get. dind-small is one + # of those org-wide, Kubernetes-backed pools; its pods reach this host on its + # private address over the VPN. The pool spans more than one cluster, so the + # source address the host sees is not fixed. + ship_runs_on: '"dind-small"' + # The staging host is arm64 (Graviton). Build natively for it. + arch: arm64 + build_runs_on: '"ubuntu-24.04-arm"' + # validate-ref's normalized output on a dispatch; on a tag push it is skipped, + # its output is empty, and github.ref (already refs/tags/rc-*) is used. + ref: ${{ needs.validate-ref.outputs.ref || github.ref }} + secrets: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_DOMAIN: ${{ secrets.DEPLOY_DOMAIN }} + DEPLOY_ACME_EMAIL: ${{ secrets.DEPLOY_ACME_EMAIL }} + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + # Submission-specific, not host-specific (see deploy-native.yml). + OPENAI_APPS_CHALLENGE_TOKEN: ${{ secrets.OPENAI_APPS_CHALLENGE_TOKEN }} + + # Attach the binary that was actually deployed to a GitHub prerelease named after + # the candidate. Without this the only copy is a workflow artifact, which expires + # after 7 days, so the revision under test would stop being retrievable while it + # is still being tested. A prerelease, so GitHub's `latest` release stays a + # promoted vX.Y.Z (the release dist creates), never a candidate. It is also the + # marker publish-crate.yml checks before promoting: the asset exists only if the + # deploy succeeded, so keep its name (imcp2-linux-arm64) in step with that check. + # + # Runs only after deploy succeeds, because an asset here asserts "this is what + # staging is running". And only on the tag-push path: a dispatch redeploys an + # existing candidate whose prerelease already carries these assets, and + # github.ref_name on a dispatch is the branch rather than the tag. + publish: + needs: deploy + # Combined with a status function on purpose: a bare event check gets an + # implicit success() over the whole upstream chain, which contains a skipped + # job on every path, and would never pass. + if: ${{ !failure() && !cancelled() && github.event_name == 'push' }} + runs-on: ubuntu-24.04 + permissions: + contents: write # create the prerelease and upload assets to it + actions: read # read this run's artifacts (the download below) + env: + GH_TOKEN: ${{ github.token }} + # No checkout in this job, so gh cannot infer the repository from a remote. + GH_REPO: ${{ github.repository }} + steps: + # GITHUB_RUN_ID scopes the download to this run, so it can only ever pick up + # the artifact the deploy above built. + - name: Download the deployed binary + run: gh run download "$GITHUB_RUN_ID" --name imcp2-binary --dir dist + + - name: Name and checksum the asset + env: + ARCH: arm64 + run: | + set -euo pipefail + mv dist/imcp2 "dist/imcp2-linux-${ARCH}" + ( cd dist && sha256sum "imcp2-linux-${ARCH}" > "imcp2-linux-${ARCH}.sha256" ) + cat "dist/imcp2-linux-${ARCH}.sha256" + + - name: Publish to the prerelease page + env: + TAG: ${{ github.ref_name }} + # From the reusable workflow, not github.sha: it records what was built. + BUILT_SHA: ${{ needs.deploy.outputs.sha }} + run: | + set -euo pipefail + notes="$(cat </dev/null 2>&1; then + gh release upload "$TAG" dist/* --clobber + else + gh release create "$TAG" --prerelease --title "$TAG" --notes "$notes" dist/* + fi diff --git a/.github/workflows/deploy-native.yml b/.github/workflows/deploy-native.yml index c13fa0f..3997388 100644 --- a/.github/workflows/deploy-native.yml +++ b/.github/workflows/deploy-native.yml @@ -1,9 +1,8 @@ # Reusable native deploy: cross-build the binary for inputs.arch (arm64 or amd64), -# then ship it over SSH and (re)start the systemd services. Called by deploy.yml -# (main -> staging) and deploy-release.yml (release-* tags -> production). Each -# caller supplies its own environment, runner labels and architecture; the release -# caller additionally pins an explicit ref, so a rollback can name an earlier tag. -# See deploy/native/README.md. +# then ship it over SSH and (re)start the systemd services. Called by +# deploy-candidate.yml (rc-* tags -> staging), which supplies the environment, +# runner labels and architecture, and on a manual run pins an explicit ref so a +# rollback can name an earlier tag. See deploy/native/README.md. # # Split into two jobs on purpose: # diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml deleted file mode 100644 index bc899cc..0000000 --- a/.github/workflows/deploy-release.yml +++ /dev/null @@ -1,250 +0,0 @@ -# Deploy a tagged release to production. -# -# Every `release-*` tag pushed to this repository is rolled out to the production -# host. Nothing else deploys here — production only ever moves when someone cuts a -# tag, so the deployed revision is always a named, reproducible point in history. -# -# Cutting a release: -# git tag release-2026-07-27 # any suffix; the prefix is what matters -# git push origin release-2026-07-27 -# -# Rolling back: re-run this workflow from the Actions tab with the previous tag as -# the `ref` input. That rebuilds and ships that exact commit — no revert commit and -# no new tag needed. -# -# Required REPOSITORY secrets (Settings -> Secrets and variables -> Actions). A job -# calling a reusable workflow with `uses:` cannot declare an `environment:`, so the -# secrets passed below resolve at repository and organization scope only — naming an -# environment-scoped secret here gets an empty value. -# -# The converse is NOT true, and it is the trap: deploy-native.yml's ship job does set -# `environment:`, so a secret defined on the `production` environment resolves there -# and OVERRIDES whatever this caller passed in. Define each secret in exactly one -# place; defining it in both means the environment copy silently wins. The -# `production` environment's protection rules apply either way. -# PROD_DEPLOY_SSH_KEY private SSH key for the sudo-capable user on the host -# PROD_DEPLOY_HOST user@host for the host's PRIVATE address, e.g. -# ec2-user@. NOT the public FQDN: that resolves -# to the public-facing interface, which sends the deploy out -# over the internet instead of the VPN, and whose security -# group does not permit 22. Staging was misconfigured this -# way and failed with a connection timeout. -# PROD_DEPLOY_DOMAIN public FQDN served over HTTPS -# PROD_DEPLOY_ACME_EMAIL email for Let's Encrypt / ACME -# Optional: -# PROD_DEPLOY_KNOWN_HOSTS output of `ssh-keyscan `; pin it to avoid TOFU. -name: Deploy release - -on: - push: - tags: ['release-*'] - # Manual re-run, and the rollback path: pass an earlier release tag as `ref`. - workflow_dispatch: - inputs: - ref: - description: >- - release-* tag (e.g. release-2026-07-27, or the fully-qualified - refs/tags/release-2026-07-27), or a full 40-character commit SHA. - required: true - type: string - -# Never run two production deploys at once; let an in-flight deploy finish rather -# than cancel it half-way through replacing the binary on the host. -concurrency: - group: deploy-production - cancel-in-progress: false - -jobs: - # A `release-*` tag push is constrained by the trigger. workflow_dispatch is not: - # its `ref` is free text, so `main` would deploy whatever that branch points at - # right now — the exact property this workflow exists to prevent. Accept only refs - # that cannot move: a release-* tag, or a full 40-character commit SHA for the case - # where the commit to roll back to never had a tag cut for it. - validate-ref: - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - outputs: - # The accepted ref in unambiguous form, consumed by deploy below. Empty when - # this job is skipped (tag push), which is what the fallback there handles. - ref: ${{ steps.normalize.outputs.ref }} - steps: - - name: Reject refs that can move - id: normalize - env: - REF: ${{ inputs.ref }} - run: | - set -euo pipefail - - # Constrain the entire input to one line of refname characters BEFORE - # looking at its shape. This step writes an input-derived value to - # $GITHUB_OUTPUT, and the dispatch input is free text that the REST API - # will happily accept newlines in. A newline turns the write below into - # multiple key=value lines, a second `ref=` line wins, and the deploy - # checks out whatever was appended while the run's inputs still show the - # innocuous value — the guard defeated, with a misleading audit trail. - # - # Anchors must bind the whole string, so this uses bash [[ =~ ]] rather - # than grep: grep is line-oriented and would match a payload's first line - # and report success for the whole thing. - # - # $REF is deliberately not echoed here — it has not been vetted yet, and - # stdout is parsed for ::workflow commands::. - if [[ ! "$REF" =~ ^[A-Za-z0-9._/-]+$ ]]; then - echo "::error::Refusing the dispatch ref: it must be a single line of [A-Za-z0-9._/-] characters. Pass a release-* tag or a full 40-character commit SHA." - exit 1 - fi - - # Strip a fully-qualified tag prefix so refs/tags/release-X is treated the - # same as release-X — it names the identical tag and is just as immutable. - # Only refs/tags/ is stripped: refs/heads/release-X must stay rejected, and - # does, because after no stripping it no longer starts with "release-". - ref="${REF#refs/tags/}" - - if [[ "$ref" =~ ^release-[A-Za-z0-9._/-]+$ ]]; then - # git's own refname grammar catches what a character class cannot: "..", - # "@{", a trailing ".lock", a leading or trailing dot, an empty path - # component. No repository required — this is a pure syntax check. - if ! git check-ref-format "refs/tags/$ref"; then - echo "::error::Refusing to deploy '$REF': not a valid git tag name." - exit 1 - fi - # Emit the tag fully qualified rather than echoing the input back. A bare - # release-X is ambiguous if a *branch* of that name also exists, and a - # textual check cannot tell them apart — refs/tags/release-X can only - # resolve to the tag, and fails outright if no such tag exists. - echo "ref=refs/tags/$ref" >> "$GITHUB_OUTPUT" - # Hex is matched case-insensitively; git resolves an uppercase SHA to the - # same commit, so rejecting it would be a surprise with no safety value. - elif [[ "$ref" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "ref=$ref" >> "$GITHUB_OUTPUT" - else - echo "::error::Refusing to deploy '$REF' to production: it is neither a release-* tag (bare or refs/tags/-qualified) nor a full 40-character commit SHA. Branches and abbreviated SHAs can move or become ambiguous, so the deployed revision would not be reproducible. Cut a release-* tag, or pass the full commit SHA." - exit 1 - fi - echo "accepted '$REF'" - - deploy: - needs: validate-ref - # validate-ref is skipped on a tag push, and a skipped dependency would skip this - # job too under the default `success()` gate. Gate on "nothing failed" instead, so - # tag pushes proceed while a rejected dispatch ref still stops the deploy. - if: ${{ !failure() && !cancelled() }} - uses: ./.github/workflows/deploy-native.yml - with: - environment: production - # The production host is on a private network (RFC1918 v4), so no - # GitHub-hosted runner can reach it. dind-small is an org-wide ARC pool whose - # pods can: staging deploys over its private address from this same pool. The - # pool's arch is irrelevant — ship only runs ssh/tar, never the binary. - # - # It is a bare name, not a label list. An ARC runner scale set is selected by - # its name alone and carries none of the default self-hosted/linux/x64 labels - # that classic runners get, so a list like ["self-hosted","linux","imcp-prod"] - # matches nothing here no matter what labels are added to the pool — an earlier - # revision of this file used exactly that and could never have deployed. - # - # Note this puts the production SSH key on shared multi-tenant pods. Accepted - # for now; a dedicated scale set is the way to isolate it later. - ship_runs_on: '"dind-small"' - # arm64, matching staging. Parity matters here: aws-lc-sys/ring carry - # per-arch assembly, so a differing production arch would leave the crypto - # paths this server's auth depends on unexercised by staging. - arch: arm64 - build_runs_on: '"ubuntu-24.04-arm"' - # Take validate-ref's normalized output, not inputs.ref — that is what makes the - # checkout unambiguous rather than merely making the input look acceptable. On a - # tag push validate-ref is skipped, so the output is empty and github.ref (already - # refs/tags/release-*) is used. - ref: ${{ needs.validate-ref.outputs.ref || github.ref }} - secrets: - DEPLOY_SSH_KEY: ${{ secrets.PROD_DEPLOY_SSH_KEY }} - DEPLOY_HOST: ${{ secrets.PROD_DEPLOY_HOST }} - DEPLOY_DOMAIN: ${{ secrets.PROD_DEPLOY_DOMAIN }} - DEPLOY_ACME_EMAIL: ${{ secrets.PROD_DEPLOY_ACME_EMAIL }} - DEPLOY_KNOWN_HOSTS: ${{ secrets.PROD_DEPLOY_KNOWN_HOSTS }} - # Submission-specific, not host-specific: deliberately NOT PROD_-prefixed; - # the same repository-level secret feeds staging and production. - OPENAI_APPS_CHALLENGE_TOKEN: ${{ secrets.OPENAI_APPS_CHALLENGE_TOKEN }} - - # Attach the binary that was actually deployed to the tag's Release page. Without - # this the only copy is a workflow artifact, which expires after 7 days, so the - # revision running in production stops being retrievable well before it stops - # running. - # - # Runs only after deploy succeeds, because an asset here asserts "this is what - # production is running" — publishing a binary whose rollout failed would make the - # Release page lie. And only on the tag-push path: a workflow_dispatch rollback - # re-deploys an existing tag whose Release already carries these assets, and - # github.ref_name on a dispatch is the branch rather than the tag, so there would - # be no correct release to attach to. - publish: - needs: deploy - # The event check must be combined with a status function, not left bare. An `if:` - # with no status function gets an implicit success() over the whole upstream chain, - # and that chain contains validate-ref, which is SKIPPED on a tag push — so a bare - # `github.event_name == 'push'` never passed on the only path this job is gated to - # run on. It was skipped on release-2026-07-29 (run 30441720101) for exactly that - # reason, having never run at all since it merged. - # - # This is the same trap the deploy job above documents and works around; the fix - # did not get carried down to here. !failure() && !cancelled() suppresses the - # implicit success(), and `needs: deploy` still means a failed rollout stops this. - if: ${{ !failure() && !cancelled() && github.event_name == 'push' }} - runs-on: ubuntu-latest - permissions: - # Everything else in this repo runs read-only. An explicit block replaces the - # defaults wholesale, so both of these have to be spelled out. - contents: write # create the Release and upload assets to it - actions: read # read this run's artifacts (the download below) - env: - GH_TOKEN: ${{ github.token }} - # gh normally infers the repository from the checkout's git remote, and this job - # deliberately has no checkout — it needs an artifact and a tag, not a worktree. - # Without this every gh call below fails on "could not determine base repository". - GH_REPO: ${{ github.repository }} - steps: - # Fetched with gh rather than actions/download-artifact so this job depends on - # nothing beyond the CLI it already uses to publish. GITHUB_RUN_ID scopes it to - # this run, so it can only ever pick up the artifact the deploy above built. - - name: Download the deployed binary - run: gh run download "$GITHUB_RUN_ID" --name imcp2-binary --dir dist - - - name: Name and checksum the asset - env: - ARCH: arm64 - run: | - set -euo pipefail - # The artifact is a bare "imcp2"; on a Release page it needs to say what it - # is, since nothing else there records the target platform. - mv dist/imcp2 "dist/imcp2-linux-${ARCH}" - ( cd dist && sha256sum "imcp2-linux-${ARCH}" > "imcp2-linux-${ARCH}.sha256" ) - cat "dist/imcp2-linux-${ARCH}.sha256" - - - name: Publish to the Release page - env: - TAG: ${{ github.ref_name }} - # From the reusable workflow, not github.sha: on a rollback those differ, - # and the whole point of this is to record what was deployed. - BUILT_SHA: ${{ needs.deploy.outputs.sha }} - run: | - set -euo pipefail - notes="$(cat </dev/null 2>&1; then - gh release upload "$TAG" dist/* --clobber - else - gh release create "$TAG" --title "$TAG" --notes "$notes" dist/* - fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index d7f3db8..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,64 +0,0 @@ -# Auto-redeploy imcp2 to staging on every push to main. -# -# Staging tracks main continuously so changes get exercised on a real host before a -# release is cut. Production is a separate host that only moves on a `release-*` -# tag — see deploy-release.yml. Both share the mechanics in deploy-native.yml, which -# cross-builds the binary and ships it over SSH to systemd services. See -# deploy/native/README.md for the deploy model. -# -# Required repository secrets (Settings -> Secrets and variables -> Actions): -# DEPLOY_SSH_KEY private SSH key (PEM/OpenSSH) for a sudo-capable user on the host -# DEPLOY_HOST user@host (matches deploy.sh's HOST). Must be an address the -# ship runner can actually reach — see the ship_runs_on comment -# below, which is what decides whether that is the host's private -# address or its public one. -# DEPLOY_DOMAIN public FQDN served over HTTPS, e.g. mcp.example.com -# DEPLOY_ACME_EMAIL email for Let's Encrypt / ACME -# Optional: -# DEPLOY_KNOWN_HOSTS host public keys (output of `ssh-keyscan `). If unset, -# the host key is fetched at run time via ssh-keyscan (TOFU). -name: Deploy main - -on: - push: - branches: [main] - # Allow manually re-running the deploy from the Actions tab. - workflow_dispatch: - -# Never run two deploys at once; let an in-flight deploy finish rather than cancel it. -concurrency: - group: deploy-staging - cancel-in-progress: false - -jobs: - deploy: - uses: ./.github/workflows/deploy-native.yml - with: - environment: staging - # A bare scale-set name, not a label list. `["self-hosted","linux"]` matched - # nothing and left the first post-#84 deploy queued indefinitely: the org's - # self-hosted capacity is ARC-managed pools, and a runner scale set is selected - # by its name alone, carrying none of the default labels classic runners get. - # - # dind-small is one of those org-wide, Kubernetes-backed pools. Its pods can - # reach this host on its private address over the VPN — confirmed by a deploy - # that shipped and passed the /version assertion from one — which is what makes - # the private-network target workable without exposing SSH to the internet. - # - # The pool spans more than one cluster and a pod can land on either, so the - # source address the host sees is not fixed. DEPLOY_HOST must name the host's - # private address for any of this to hold; see deploy-native.yml's description - # of that secret. - ship_runs_on: '"dind-small"' - # Staging host is arm64 (Graviton). - arch: arm64 - build_runs_on: '"ubuntu-24.04-arm"' - secrets: - DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} - DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} - DEPLOY_DOMAIN: ${{ secrets.DEPLOY_DOMAIN }} - DEPLOY_ACME_EMAIL: ${{ secrets.DEPLOY_ACME_EMAIL }} - DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} - # Submission-specific, not host-specific: the same repository-level - # secret feeds staging and production (see deploy-native.yml). - OPENAI_APPS_CHALLENGE_TOKEN: ${{ secrets.OPENAI_APPS_CHALLENGE_TOKEN }} diff --git a/.github/workflows/imcp2-local-install-note.yml b/.github/workflows/imcp2-local-install-note.yml index 3127563..d7b0a3b 100644 --- a/.github/workflows/imcp2-local-install-note.yml +++ b/.github/workflows/imcp2-local-install-note.yml @@ -1,5 +1,5 @@ # Prepends a plain-language summary of what the install scripts do to the -# release notes `dist` writes for an `imcp2-local-v*` release. +# release notes `dist` writes for a `vX.Y.Z` release. # # Why a job rather than a setting: dist builds the release body itself (the # install one-liners, the download table, the attestation section) and has no diff --git a/.github/workflows/imcp2-local-mcpb.yml b/.github/workflows/imcp2-local-mcpb.yml index 72e77b1..1f787cd 100644 --- a/.github/workflows/imcp2-local-mcpb.yml +++ b/.github/workflows/imcp2-local-mcpb.yml @@ -1,5 +1,5 @@ -# Builds the Claude Desktop bundle (`imcp2-local.mcpb`) for an -# `imcp2-local-v*` release, attests it, and attaches it to that release. +# Builds the Claude Desktop bundle (`imcp2-local.mcpb`) for a `vX.Y.Z` +# release, attests it, and attaches it to that release. # # Hung off `post-announce-jobs` in dist-workspace.toml: dist has no notion of # an MCPB artifact, and the bundle needs the finished per-platform archives diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index 4e7fd93..5c932d2 100644 --- a/.github/workflows/publish-crate.yml +++ b/.github/workflows/publish-crate.yml @@ -1,17 +1,20 @@ -# Publish the crate to crates.io from a version tag. +# Publish the crates to crates.io from a version tag. # -# Every `v*` tag pushed to this repository publishes that exact commit to -# crates.io. Deliberately a different prefix from `release-*` (deploy-release.yml, -# which ships the production host): a library release and a production rollout are -# separate events and should not be forced to happen together. +# Every `vX.Y.Z` tag pushed to this repository promotes a release candidate: it +# publishes that exact commit to crates.io, and the same tag runs v-release.yml +# (generated by dist), which ships imcp2-local's binaries. Nothing is deployed by +# it — production embeds the published crate — and the commit must already have +# been deployed to staging as a candidate, an `rc-X.Y.Z-N` tag cut by +# deploy-candidate.yml. # -# Cutting a crate release: -# # bump `version` in Cargo.toml, land it on main, then from that commit: -# git tag v0.1.1 -# git push origin v0.1.1 +# Promoting a candidate: +# # the version bump landed on main before rc-0.6.0-1 was cut; then: +# git tag v0.6.0 rc-0.6.0-1^{} # the candidate's commit, exactly +# git push origin v0.6.0 # -# The tag must match Cargo.toml's `version` exactly and must point at a commit on -# `main` — the guards below fail the run rather than publishing. There is no +# The tag must match every Cargo.toml `version` exactly and must point at a commit +# on `main` that an `rc-X.Y.Z-N` candidate was deployed from, successfully — the +# guards below fail the run rather than publishing. There is no # rollback: crates.io releases are permanent and a bad one can only be yanked # (`cargo yank --version X.Y.Z`), which hides it from new dependency resolution but # leaves it downloadable for anyone whose lockfile already pins it. The fix for a @@ -41,7 +44,8 @@ # enabled. Neither can be expressed in this file, and without them the guards below # are only mistake-guards: # -# * Protected tags for `v*` (Settings -> Rules -> Rulesets, target: tags). GitHub +# * Protected tags for `v*` and `rc-*` (Settings -> Rules -> Rulesets, target: +# tags). GitHub # loads a tag-triggered workflow FROM THE TAGGED COMMIT, so whoever can push a # `v*` tag can point it at an unreviewed commit carrying a doctored copy of this # file — one with the guards deleted. That run still satisfies the trusted @@ -107,13 +111,14 @@ jobs: # A tag that disagrees with the manifests publishes a version under a name # nobody can check out — and crates.io cannot take it back. Read the versions # from cargo itself rather than grepping the manifests, so the check reads the - # same field `cargo publish` will. Both workspace crates release in lockstep: - # one tag, one version, two packages (imcp2 depends on imcp2-core "=version" - # semantics via the workspace pin, so a skew would not resolve anyway). - - name: Check the tag matches both Cargo.toml versions + # same field `cargo publish` will. The three workspace crates release in + # lockstep: one tag, one version (imcp2 depends on imcp2-core "=version" + # semantics via the workspace pin, so a skew would not resolve anyway), and + # imcp2-local's version is what dist matches this same tag against. + - name: Check the tag matches every Cargo.toml version run: | tag="${GITHUB_REF_NAME#v}" - for pkg in imcp2-core imcp2; do + for pkg in imcp2-core imcp2 imcp2-local; do manifest="$(cargo metadata --format-version 1 --no-deps | jq -er --arg pkg "$pkg" '.packages[] | select(.name == $pkg) | .version')" echo "tag=$tag $pkg=$manifest" @@ -137,6 +142,37 @@ jobs: exit 1 fi + # A release is a promoted candidate: an rc-X.Y.Z-N tag for this very version + # must point at the commit, AND that candidate's prerelease must carry the + # deployed binary. deploy-candidate.yml uploads it only after the staging + # deploy succeeded, so it is the marker that the commit was deployed and could + # be tested; the tag alone proves only that a tag was pushed. A v tag pushed + # before the candidate's deploy finished fails here and is re-run afterwards. + # The candidate tags are fetched explicitly rather than relying on the + # checkout having brought them along. + - name: Check the tag promotes a deployed candidate + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME#v}" + git fetch --no-tags origin "+refs/tags/rc-*:refs/tags/rc-*" + deployed="" + for rc in $(git tag --points-at "${GITHUB_SHA}" | grep -Ex "rc-${tag//./\\.}-[0-9]+" || true); do + if gh release view "$rc" --json isPrerelease,assets \ + --jq 'select(.isPrerelease) | .assets[].name' 2>/dev/null | grep -qx imcp2-linux-arm64; then + deployed="$rc" + break + fi + echo "$rc points at this commit but has no prerelease carrying the deployed binary" + done + if [ -z "$deployed" ]; then + echo "::error::${GITHUB_REF_NAME} does not promote a deployed candidate: no rc-${tag}-N tag on this commit has a prerelease with the staging binary. Cut a candidate (deploy-candidate.yml), let its deploy finish, then re-run this workflow." >&2 + exit 1 + fi + echo "promotes ${deployed}, whose staging deploy succeeded" + # ci.yml runs on pull requests and on pushes to main; a tag can point at any # commit, so re-run the suite against the exact revision about to become an # immutable public release. `--locked` also guards Cargo.lock from drift, and diff --git a/.github/workflows/imcp2-local-release.yml b/.github/workflows/v-release.yml similarity index 99% rename from .github/workflows/imcp2-local-release.yml rename to .github/workflows/v-release.yml index 8383856..4d51957 100644 --- a/.github/workflows/imcp2-local-release.yml +++ b/.github/workflows/v-release.yml @@ -42,7 +42,7 @@ on: pull_request: push: tags: - - 'imcp2-local**[0-9]+.[0-9]+.[0-9]+*' + - 'v**[0-9]+.[0-9]+.[0-9]+*' jobs: # Run 'dist plan' (or host) to determine what tasks we need to do diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a353e0..df43def 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,42 +122,61 @@ instructions. 5. A maintainer will review. Address feedback by pushing follow-up commits to the same branch. -## Releasing to crates.io - -`imcp2` is published as a library crate. Releases are cut from a version tag: -bump `version` in `Cargo.toml`, land that on `main`, then tag that commit and -push the tag. +## Releasing + +A release takes two human actions, both tag pushes, and all three crates +(`imcp2`, `imcp2-core`, `imcp2-local`) share one version. + +1. **Land the version bump on `main`.** Set `version` in `Cargo.toml`, + `crates/imcp2-core/Cargo.toml` and `crates/imcp2-local/Cargo.toml`, plus the + workspace's `imcp2-core` pin, refresh `Cargo.lock` (`cargo update -w`), and + merge that like any other change. +2. **Cut a candidate.** Tag that commit `rc-X.Y.Z-1` and push the tag. + `.github/workflows/deploy-candidate.yml` checks that the tag names the + manifests' version and that the commit is on `main`, deploys it to staging, + and publishes a GitHub prerelease carrying the deployed binary. Test it + there. If it fails, fix on `main` and cut `rc-X.Y.Z-2`: nothing is bumped + after a candidate, so the commit that is promoted is the one that was tested. +3. **Promote.** Tag the same commit `vX.Y.Z` and push. Two workflows run from + it, and nothing is deployed by either: + - `.github/workflows/publish-crate.yml` publishes `imcp2-core` and `imcp2` to + crates.io. It checks the tag against every `Cargo.toml`, that the commit is + on `main`, and that an `rc-X.Y.Z-N` tag on it has its prerelease with the + deployed binary (which exists only if the staging deploy succeeded), runs + the suite and a dry-run package, and only then publishes from a second job + that compiles nothing. + It authenticates with crates.io + [trusted publishing](https://crates.io/docs/trusted-publishing) (short-lived + OIDC credentials), so there is no crates.io token in this repository's + secrets and none should be added. + - `.github/workflows/v-release.yml` (generated by `dist` from + `dist-workspace.toml`) builds `imcp2-local`'s platform archives, + installers and Claude Desktop bundle and creates the `vX.Y.Z` GitHub + release. Candidates are prereleases, so GitHub's `latest` release is + always a promoted version. + + Production is not deployed from this repository: it embeds the published + crate, and picks the new version up with a dependency bump there. ```sh -git tag v0.1.1 -git push origin v0.1.1 +git tag rc-0.6.0-1 && git push origin rc-0.6.0-1 # candidate -> staging +git tag v0.6.0 rc-0.6.0-1^{} && git push origin v0.6.0 # promote ``` -`.github/workflows/publish-crate.yml` takes it from there — it checks the tag -against `Cargo.toml`, checks the tagged commit is on `main`, runs the suite and -a dry-run package, and only then publishes from a second job that compiles -nothing. It authenticates with crates.io -[trusted publishing](https://crates.io/docs/trusted-publishing) (short-lived -OIDC credentials), so there is no crates.io token in this repository's secrets -and none should be added. - Two repository settings are prerequisites, because GitHub loads a -tag-triggered workflow from the tagged commit — so the guards in the workflow -file cannot defend against a tag that carries its own edited copy of them: +tag-triggered workflow from the tagged commit, so the guards in the workflow +files cannot defend against a tag that carries its own edited copy of them: -- **Protected `v*` tags** (Settings → Rules → Rulesets, targeting tags), so - only maintainers can start a release at all. +- **Protected `v*` and `rc-*` tags** (Settings → Rules → Rulesets, targeting + tags), so only maintainers can cut a candidate or promote one. - **Required reviewers on the `release` environment** (Settings → Environments), which gates the one job that can reach crates.io from outside the workflow file. -The workflow header documents these along with the one-time crates.io -configuration. - -Note that `v*` tags are for crate releases only; production rollouts are cut -separately with `release-*` tags (see `.github/workflows/deploy-release.yml`). -A crates.io release is permanent — a bad one can only be yanked, and a version -number can never be reused — so the fix for a bad release is a new version. +The publish workflow's header documents these along with the one-time +crates.io configuration. A crates.io release is permanent: a bad one can only +be yanked, and a version number can never be reused, so the fix for a bad +release is a new version, cut through the same two steps. ## Coding guidelines diff --git a/README.md b/README.md index 525c150..676aa16 100644 --- a/README.md +++ b/README.md @@ -607,9 +607,10 @@ when hosting: A `Dockerfile` is included (works on Render / Fly / Cloud Run / Koyeb). The reference deployment (`deploy/native/`, see its README) instead runs the binary -and Caddy as native systemd units: pushes to `main` auto-deploy staging, and -`release-*` tags deploy production, attaching the deployed binary to the GitHub -release. For a +and Caddy as native systemd units: an `rc-X.Y.Z-N` tag deploys that candidate to +staging (attaching the deployed binary to a GitHub prerelease), and the matching +`vX.Y.Z` tag publishes the crates and the local MCP binaries; production embeds +the published crate. For a zero-signup public URL during testing, expose the local server with a tunnel: ```bash diff --git a/SECURITY.md b/SECURITY.md index 186649f..d37338e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,7 +36,8 @@ We are committed to fixing security vulnerabilities in a timely manner. Once a s This project is under active development and has not yet reached a stable `1.0` release. Security fixes are applied to the latest revision on the `main` branch -and reach the deployment on the next `release-*` tag. +and ship in the next release: a `vX.Y.Z` tag promoted from a staging candidate, +which publishes the crates production embeds and the local MCP binaries. ## Preferred Language diff --git a/crates/imcp2-local/Cargo.toml b/crates/imcp2-local/Cargo.toml index 3762f20..0e5b015 100644 --- a/crates/imcp2-local/Cargo.toml +++ b/crates/imcp2-local/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/dfinity/imcp2" publish = false # The one dist-released app in this workspace: release binaries + installers -# are built by dist (see dist-workspace.toml) on `imcp2-local-v*` tags. +# are built by dist (see dist-workspace.toml) on the `vX.Y.Z` release tags. [package.metadata.dist] dist = true diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index 9291e0c..38425c2 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -17,7 +17,9 @@ cannot spawn local processes; they keep using the hosted server. ## Install Release binaries (macOS arm64/x64, Linux x64/arm64, Windows x64) ship from -this repository's GitHub releases, built by `dist` from `imcp2-local-v*` tags. +this repository's GitHub releases, built by `dist` from the `vX.Y.Z` tags that +also publish the crates. Candidates (`rc-*`) are prereleases, so GitHub's +`latest` release is always a promoted version. **Verified install.** This binary acts as your Internet Identity, so prefer the path that establishes where the artifact came from. Download the archive, check @@ -25,13 +27,8 @@ that this repository's release workflow built it for that very release, then install it into a directory on your `PATH`: ```sh -# Resolve the newest binary release. `releases/latest` is NOT this crate's: -# production deploys publish `release-*` releases in this same repository, so -# the repository's latest release is usually one of those. Paginate rather -# than take a first page, for the same reason — this crate's tag is a small -# minority of the releases here. -TAG=$(gh api --paginate repos/dfinity/imcp2/releases --jq '.[].tag_name' \ - | awk '/^imcp2-local-v/ && !seen++') +# Resolve the newest release: candidates are prereleases and never `latest`. +TAG=$(gh api repos/dfinity/imcp2/releases/latest --jq .tag_name) TARGET=aarch64-apple-darwin # or x86_64-apple-darwin, {x86_64,aarch64}-unknown-linux-gnu # Chained: a failed download or a failed attestation stops the install. @@ -39,7 +36,7 @@ TARGET=aarch64-apple-darwin # or x86_64-apple-darwin, {x86_64,aarch64}-unknown # across releases, so without it an older release's archive would pass. curl -fLO "https://github.com/dfinity/imcp2/releases/download/$TAG/imcp2-local-$TARGET.tar.xz" && gh attestation verify "imcp2-local-$TARGET.tar.xz" -R dfinity/imcp2 \ - --signer-workflow dfinity/imcp2/.github/workflows/imcp2-local-release.yml \ + --signer-workflow dfinity/imcp2/.github/workflows/v-release.yml \ --source-ref "refs/tags/$TAG" --deny-self-hosted-runners && tar xf "imcp2-local-$TARGET.tar.xz" && mkdir -p ~/.local/bin && @@ -50,6 +47,12 @@ curl -fLO "https://github.com/dfinity/imcp2/releases/download/$TAG/imcp2-local-$ two commands create it and copy the binary there, nothing edits your shell configuration. +Releases before 0.6.0 predate this scheme: they are tagged `imcp2-local-vX.Y.Z` +(the newest is `imcp2-local-v0.5.0`), are not what `latest` resolves to, and +their archives were attested by `imcp2-local-release.yml`, the workflow's name +at the time. To install one of those, set `TAG` to its tag and pass that +filename to `--signer-workflow`; everything else above is the same. + (Windows ships `imcp2-local-x86_64-pc-windows-msvc.zip`; verify it the same way.) **Installer script.** Shorter, and what the release notes lead with. It @@ -65,9 +68,9 @@ catches a corrupted download rather than a bad release. On both platforms the attestation above is what establishes provenance. ```sh -# Substitute the newest imcp2-local-v* tag; each release's notes carry the -# current command, and `releases/latest` is not this crate's release (above). -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/imcp2/releases/download/imcp2-local-v0.5.0/imcp2-local-installer.sh | sh +# `latest` is the newest promoted release; each release's notes carry the +# same command pinned to that release's tag. +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/imcp2/releases/latest/download/imcp2-local-installer.sh | sh ``` **Claude Desktop bundle.** Each release also carries `imcp2-local.mcpb`. @@ -183,7 +186,7 @@ releases, so the tag is what tells this release's archive from an older one's: ```sh # : the release the archive came from. (Windows archives are .zip.) gh attestation verify imcp2-local-.tar.xz -R dfinity/imcp2 \ - --signer-workflow dfinity/imcp2/.github/workflows/imcp2-local-release.yml \ + --signer-workflow dfinity/imcp2/.github/workflows/v-release.yml \ --source-ref refs/tags/ --deny-self-hosted-runners ``` @@ -198,11 +201,14 @@ installers, which are not subject to those OS gates. ## Releasing (maintainers) -Binary releases are cut by pushing an `imcp2-local-vX.Y.Z` tag (the version -must match this crate's `Cargo.toml`). Those tags must stay covered by the same -protected-tag ruleset as `v*`: the generated workflow publishes whatever commit -the tag names, so who can push the tag is the control that decides what ships; `.github/workflows/imcp2-local-release.yml` -(generated by `dist` from `dist-workspace.toml`) builds the five platform -archives, the installers, the updater companions, checksums, and the GitHub -attestations. Plain `vX.Y.Z` tags remain the crates.io publish trigger for -`imcp2`/`imcp2-core` and never ship binaries. +Binary releases ship with every promoted version: the `vX.Y.Z` tag that +publishes the crates also runs `.github/workflows/v-release.yml` (generated by +`dist` from `dist-workspace.toml`), which builds the five platform archives, the +installers, the updater companions, checksums, the GitHub attestations, and then +the Claude Desktop bundle. The version must match this crate's `Cargo.toml`; +the candidate workflow checks that before anything is deployed to staging, so a +mismatched candidate never gets a prerelease and cannot be promoted (see the +repository's CONTRIBUTING.md, "Releasing"), and dist checks it again. +`v*` and `rc-*` tags must stay covered by the protected-tag ruleset: the +generated workflow publishes whatever commit the tag names, so who can push the +tag is the control that decides what ships. diff --git a/deploy/native/README.md b/deploy/native/README.md index 3087309..c11d78d 100644 --- a/deploy/native/README.md +++ b/deploy/native/README.md @@ -30,9 +30,10 @@ The **status dashboard** (`monitoring/mcp-status`) is also shipped and run as a Node systemd service (`imcp-status.service`) bound to `127.0.0.1:8137`. On **staging only**, Caddy publishes it at **`https://$DOMAIN/status/`**, where it probes the deployment's own public endpoints and the linked Internet Identity -instance; production runs the same service (its Statuspage pusher feeds the -public [status.internetcomputer.org](https://status.internetcomputer.org/)) but -does not expose it at its origin (see `SERVE_STATUS` below). Node ≥ 20 is +instance; a host deployed without `SERVE_STATUS` (see below) runs the same service +(its Statuspage pusher feeds the public +[status.internetcomputer.org](https://status.internetcomputer.org/)) but does not +expose it at its origin. Node ≥ 20 is installed automatically on first deploy if absent; the dashboard has no build step and no third-party dependencies. @@ -165,32 +166,31 @@ being able to `ssh` in does not mean 80/443 are reachable from the internet. that there's no public IPv4 inbound, and that AWS creds are granted — ask it to make the box publicly reachable and report the address to set DNS *before* the deploy. -## Automated deploys: staging on `main`, production on `release-*` - -Two hosts, two triggers, one shared mechanism: +## Automated deploys: one staging host, moved by candidates | Workflow | Trigger | Target | Environment | |---|---|---|---| -| [`deploy.yml`](../../.github/workflows/deploy.yml) | push to `main` | staging | `staging` | -| [`deploy-release.yml`](../../.github/workflows/deploy-release.yml) | push tag `release-*` | production | `production` | - -Staging tracks `main` continuously so changes get exercised on a real host; production -only ever moves when someone cuts a tag, so the live revision is always a named, -reproducible point in history. - -The staging deploy additionally sets `MCP_SERVE_BETA=1` (via `deploy-native.yml`, -substituted into the unit by `deploy.sh`), so staging serves the beta Internet -Identity instance at `/mcp-beta` alongside the production `/mcp`. Production leaves -it unset, so it serves `/mcp` (production II) alone. - -Staging alone also sets `SERVE_STATUS=1`. `deploy.sh` then keeps the Caddyfile's -`/status/` block (between its `__STATUS_BEGIN__`/`__STATUS_END__` markers), so the -status dashboard is published at `https:///status/`; for production +| [`deploy-candidate.yml`](../../.github/workflows/deploy-candidate.yml) | push tag `rc-X.Y.Z-N`; or a manual run naming an earlier `rc-*` tag or a commit SHA | staging | `staging` | + +Staging is the only host this repository deploys, and it moves only when someone cuts +a candidate, so the revision under test is a named, reproducible point in history that +no merge to `main` changes underneath the tester. Production is not deployed from here: +it embeds the `imcp2` crate, which the matching `vX.Y.Z` tag publishes once the +candidate has been tested (see [CONTRIBUTING.md](../../CONTRIBUTING.md), "Releasing"). + +The deploy sets `MCP_SERVE_BETA=1` for the `staging` environment (via +`deploy-native.yml`, substituted into the unit by `deploy.sh`), so staging serves the +beta Internet Identity instance at `/mcp-beta` alongside the production `/mcp`; a +binary run without it serves `/mcp` (production II) alone. + +The `staging` environment also sets `SERVE_STATUS=1`. `deploy.sh` then keeps the +Caddyfile's `/status/` block (between its `__STATUS_BEGIN__`/`__STATUS_END__` markers), +so the status dashboard is published at `https:///status/`; without it the block is deleted and the dashboard is not published at the origin. The public status surface for ICP MCP is [status.internetcomputer.org](https://status.internetcomputer.org/), driven by the dashboard's Statuspage pusher, which keeps running on every host. -Both hosts set `MCP_SERVE_METRICS=1` (hardcoded in the unit), so the app serves +The unit hardcodes `MCP_SERVE_METRICS=1`, so the app serves the Prometheus exposition at `/metrics` on its own port for a scraper to reach on the host's private address. Caddy answers `/metrics` with a 404, which is what keeps it off the internet. `deploy.sh` asserts that 404 against Caddy on the host itself, @@ -199,21 +199,40 @@ response rather than Caddy's; it separately fails the deploy if the exposition i ever shows up through the public name, redirects followed. The mechanics live in [`deploy-native.yml`](../../.github/workflows/deploy-native.yml), -a reusable workflow both call. It first runs the status dashboard's unit tests (a +a reusable workflow. It first runs the status dashboard's unit tests (a regression there stops the rollout), cross-builds the binary with `build.sh`, then runs `deploy.sh` over SSH — which ships and (re)starts the app and the dashboard service. A -`concurrency` group per environment serializes deploys so two never overlap. +`concurrency` group serializes deploys so two never overlap. -### Cutting a release +### Cutting a candidate ```sh -git tag release-2026-07-27 # any suffix; the `release-` prefix is the trigger -git push origin release-2026-07-27 +# the version bump (all three Cargo.toml files) has landed on main; then: +git tag rc-0.6.0-1 +git push origin rc-0.6.0-1 ``` -**Rolling back:** re-run `deploy-release.yml` from the Actions tab with an earlier tag -as the `ref` input. That rebuilds and ships that exact commit — no revert commit, no -new tag. +The workflow refuses a tag whose version does not match the manifests or whose commit +is not on `main`, deploys, and publishes a GitHub prerelease `rc-0.6.0-1` carrying the +deployed binary. A failed candidate is followed by a fix on `main` and `rc-0.6.0-2`; +nothing is bumped after a candidate, so the commit that is promoted is the one tested. + +### Promoting a candidate + +```sh +git tag v0.6.0 rc-0.6.0-1^{} # the candidate's commit, exactly +git push origin v0.6.0 +``` + +Nothing is deployed by this. `publish-crate.yml` publishes the crates, refusing a +commit unless an `rc-0.6.0-N` tag on it has its prerelease with the deployed binary, +which exists only if that candidate's staging deploy succeeded; `v-release.yml` ships +the local MCP binaries. Production picks the new crate up with a dependency bump of +its own. + +**Rolling back staging:** run `deploy-candidate.yml` from the Actions tab with an +earlier `rc-*` tag (or a full commit SHA) as the `ref` input. That rebuilds and ships +that exact commit — no revert commit, no new tag. ### The build/ship split @@ -222,8 +241,8 @@ new tag. **ship** runs wherever the target host is reachable and needs only `ssh` + `tar`, no Docker and no Rust. -That split is what lets the ship job run on a **self-hosted runner**, which is how both -environments deploy: the host is reached on its private address over the VPN rather than +That split is what lets the ship job run on a **self-hosted runner**, which is how +staging deploys: the host is reached on its private address over the VPN rather than over the public internet. The heavy build stays on hosted infrastructure and only the binary crosses into the private network. The ship runner's own architecture is irrelevant; it never executes the binary. @@ -287,7 +306,7 @@ nothing but internet-sourced attempts. The rule permitting `22` from the private no IPv6 entry either, so nothing on the v6 side was relying on it. `ship_runs_on` takes a JSON string, so a bare name is `'"dind-small"'` and a label set -is `'["self-hosted","linux"]'`. Both deploys use the bare-name form, because the org's +is `'["self-hosted","linux"]'`. The deploy uses the bare-name form, because the org's self-hosted capacity is **ARC runner scale sets** — a scale set is selected by its name alone and carries none of the `self-hosted` / `linux` / `x64` labels that classic runners get automatically. A label list therefore matches no scale set however the pool @@ -300,21 +319,21 @@ kept the old binary fails the run instead of passing quietly. ### Secrets -Staging and production take separate secrets so a production rollout can never be -pointed at the wrong box by a stale value. Both hosts are reached on their **private** -addresses, since the ship job runs inside the VPN. +The host is reached on its **private** address, since the ship job runs inside the +VPN. (The `PROD_*` secrets and the `production` environment that an earlier +production deploy used are no longer read by any workflow and can be deleted.) -| Staging | Production | Value | -|---|---|---| -| `DEPLOY_SSH_KEY` | `PROD_DEPLOY_SSH_KEY` | Private SSH key for the sudo-capable host user | -| `DEPLOY_HOST` | `PROD_DEPLOY_HOST` | `user@host`, the host's private address | -| `DEPLOY_DOMAIN` | `PROD_DEPLOY_DOMAIN` | Public FQDN served over HTTPS | -| `DEPLOY_ACME_EMAIL` | `PROD_DEPLOY_ACME_EMAIL` | Email for Let's Encrypt / ACME | -| `DEPLOY_KNOWN_HOSTS` | `PROD_DEPLOY_KNOWN_HOSTS` | *(optional)* output of `ssh-keyscan `; pin it to avoid trust-on-first-use | -| `OPENAI_APPS_CHALLENGE_TOKEN` | *(same name)* | *(optional)* OpenAI Apps domain-verification token, served at `/.well-known/openai-apps-challenge` (404 while unset). Submission-specific rather than host-specific, so one repository-level secret feeds both environments | +| Secret | Value | +|---|---| +| `DEPLOY_SSH_KEY` | Private SSH key for the sudo-capable host user | +| `DEPLOY_HOST` | `user@host`, the host's private address | +| `DEPLOY_DOMAIN` | Public FQDN served over HTTPS | +| `DEPLOY_ACME_EMAIL` | Email for Let's Encrypt / ACME | +| `DEPLOY_KNOWN_HOSTS` | *(optional)* output of `ssh-keyscan `; pin it to avoid trust-on-first-use | +| `OPENAI_APPS_CHALLENGE_TOKEN` | *(optional)* OpenAI Apps domain-verification token, served at `/.well-known/openai-apps-challenge` (404 while unset) | One optional setting is a GitHub Environment **variable** rather than a secret -(**Settings → Environments → *staging* / *production* → Variables**); `deploy.sh` +(**Settings → Environments → *staging* → Variables**); `deploy.sh` renders it into the unit like the secrets above: | Variable | Value | @@ -343,10 +362,12 @@ workflow only automates the build-and-ship step, not provisioning the box. ### Approval gate -Each deploy job runs in its named GitHub Environment. To require manual approval -before production rollouts, go to **Settings → Environments → production** and add -yourself (or a team) as a **Required reviewer**. Until a reviewer is configured the -environment imposes no gate, so a `release-*` tag deploys straight through. +The ship job runs in the `staging` GitHub Environment, which imposes no gate: a +candidate tag deploys straight through, and the tag push itself is the human decision +(the tag ruleset decides who can make it). Should you want a second pair of eyes, go +to **Settings → Environments → staging** and add a **Required reviewer**. The approval +that matters for a release is the one on the `release` environment, which gates the +crates.io publish (see `publish-crate.yml`'s header). ## Operating diff --git a/dist-workspace.toml b/dist-workspace.toml index 0576d42..f5f61eb 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -1,8 +1,8 @@ # Release configuration for the `imcp2-local` binary (the design's end-user # distribution channel), built with dist (cargo-dist) following the ICP CLI's # setup. Only `imcp2-local` is distributed (see `[package.metadata.dist]` in -# the crate manifests); the `imcp2`/`imcp2-core` crates release to crates.io -# through .github/workflows/publish-crate.yml instead. +# the crate manifests); the same `vX.Y.Z` tag publishes the `imcp2`/`imcp2-core` +# crates to crates.io through .github/workflows/publish-crate.yml. [workspace] members = ["cargo:."] @@ -20,8 +20,12 @@ targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-da install-path = "CARGO_HOME" # Whether to install an updater program install-updater = true -# A prefix git tags must include for dist to care about them -tag-namespace = "imcp2-local" +# A prefix git tags must include for dist to care about them: the promotion +# tags (`vX.Y.Z`). Candidates are `rc-X.Y.Z-N` and must stay outside this +# trigger, because dist matches a tag's version against Cargo.toml exactly and +# a candidate tag would fail its plan. The namespace also names the generated +# workflow, .github/workflows/v-release.yml. +tag-namespace = "v" # Whether to enable GitHub Attestations github-attestations = true # Prepend a plain-language summary of what the install scripts do to the diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index a199422..1a918ae 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -274,8 +274,8 @@ document describes. What can NO longer be confirmed externally is the exact commit: `mcp.internetcomputer.org` now sits behind Internet Computer HTTP-gateway infrastructure that forwards only the MCP and OAuth paths, so `/version` answers with a redirect at that edge instead of the build report. -Before submitting, have the operators confirm production runs a `release-*` -tag cut from current `main` (on-host `curl localhost:8000/version`, or the +Before submitting, have the operators confirm production runs a `vX.Y.Z` +release cut from current `main` (on-host `curl localhost:8000/version`, or the deploy workflow's record) — or have the fronting layer forward `/version` again so the check works from anywhere. @@ -435,7 +435,7 @@ conversation beyond tool arguments and generates no media. - [ ] Privacy policy entered in the portal — enter `https://internetcomputer.org/icp-mcp/privacy-policy/`, the page's one home (live; the old mcp.internetcomputer.org URL permanently redirects there) (blocker 1) - [x] Financial-transactions acknowledgment is a clean yes (blocker 2): the server does not support financial transactions. No mcp-review reply is needed; if one arrives, answer with the stated posture. The first-party-API/data-handling question was NOT in the 2026-07-31 email: raise it with mcp-review only if the portal's data-handling options don't fit - [x] Reviewer access settled: self-serve Internet Identity, instructions in the test-credentials field (blocker 3) — if a reviewer asks for a populated account, provision a demo-app account (no funding needed: there are no funding or canister-management tools) -- [ ] `release-*` tag cut; production confirmed to run the intended commit by the operators — externally `/version` is cut off by the gateway front, so the check is on-host or via the deploy workflow's record (blocker 4) +- [ ] `vX.Y.Z` release cut (promoted from a staging candidate) and picked up by the gateway; production confirmed to run the intended commit by the operators — externally `/version` is cut off by the gateway front, so the check is on-host or via the deploy workflow's record (blocker 4) - [x] Square PNG icon exported — `docs/assets/icp-logo-{1024,512}.png` (blocker 5) - [ ] Every tool exercised once by the submitter (portal asks you to confirm this; MCP Inspector or a custom connector in Claude both count) - [ ] Submitter has Owner / Directory-management access in DFINITY's Claude Team/Enterprise org